Compare commits

..

10 Commits

Author SHA1 Message Date
bwees
853943aba1 feat: asset dimensions in asset table 2025-12-03 21:33:06 -06:00
shenlong
bbba1bfe8c fix: use adjustment time in iOS for hash reset (#24047)
* use adjustment time in iOS for hash reset

* migration

* fix equals check

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-12-03 21:15:58 -06:00
Robert Schäfer
4be9a5ebf8 fix(docs): obsolete docs about rootless docker (#24376)
Description
-----------

The documentation lies about comments in `docker/docker-compose.dev.yml`.

Reason: in 689c6aa276 these docs were added
but the comments in this file are removed in
b9e2590752 and the docs weren't updated.

How Has This Been Tested?
-------------------------
```
$ git log -S rootless

commit b9e2590752
Author: Jason Rasmussen <jason@rasm.me>
Date:   Tue Sep 16 12:48:44 2025 -0400

    chore: simplify (#22082)

commit 689c6aa276
Author: Rudolf Horváth <R-Rudolf@users.noreply.github.com>
Date:   Thu Nov 21 13:25:45 2024 +0100

    docs: add developer notes about rootless docker setup (#13250)
```

Checklist:
----------

- [x] I have performed a self-review of my own code
- [x] I have made corresponding changes to the documentation if applicable
- [x] I have no unrelated changes in the PR.
- [ ] I have confirmed that any new dependencies are strictly necessary.
- [ ] I have written tests for new code (if applicable)
- [ ] I have followed naming conventions/patterns in the surrounding code
- [ ] All code in `src/services/` uses repositories implementations for database calls, filesystem operations, etc.
- [ ] All code in `src/repositories/` is pretty basic/simple and does not have any immich specific logic (that belongs in `src/services/`)
2025-12-03 18:34:08 -06:00
Omar I
d41921247b fix(web): Add minimum content size to logo for consistent visual on small screens (#24372) 2025-12-03 21:35:48 +00:00
Nicholas
853a024f0f fix: prevent OOM on nginx reverse proxy servers (#24351)
Prevent OOM on reverse proxy servers

Added configuration to disable buffering for uploads.
2025-12-03 14:30:28 -06:00
Alex
4fe494776e fix: local full sync on Android on resume (#24348) 2025-12-03 20:22:07 +00:00
Justin Forseth
76b4adf276 fix: Adjust the zoom level (#24353)
Adjust the zoom level
2025-12-03 14:19:57 -06:00
Alex
75dde0d076 fix: exposure info and better readability (#24344)
fix: exposure info and better readabilit
2025-12-03 20:19:45 +00:00
Mert
cffb68d1c4 fix(server): do not delete offline assets (#24355)
* do not delete isOffline assets

* update sql

* add medium test

* add normal delete test

* formatting
2025-12-03 14:19:26 -06:00
Jason Rasmussen
45f68f73a9 feat: queue detail page (#24352) 2025-12-03 13:39:32 -05:00
95 changed files with 10309 additions and 709 deletions

View File

@@ -41,7 +41,7 @@ By default, Immich will keep the last 14 database dumps and create a new dump ev
#### Trigger Dump
You are able to trigger a database dump in the [admin job status page](http://my.immich.app/admin/jobs-status).
You are able to trigger a database dump in the [admin job status page](http://my.immich.app/admin/queues).
Visit the page, open the "Create job" modal from the top right, select "Create Database Dump" and click "Confirm".
A job will run and trigger a dump, you can verify this worked correctly by checking the logs or the `backups/` folder.
This dumps will count towards the last `X` dumps that will be kept based on your settings.

View File

@@ -21,6 +21,9 @@ server {
# allow large file uploads
client_max_body_size 50000M;
# disable buffering uploads to prevent OOM on reverse proxy server and make uploads twice as fast (no pause)
proxy_request_buffering off;
# Set headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;

View File

@@ -48,7 +48,6 @@ You can access the web from `http://your-machine-ip:3000` or `http://localhost:3
**Notes:**
- The "web" development container runs with uid 1000. If that uid does not have read/write permissions on the mounted volumes, you may encounter errors
- In case of rootless docker setup, you need to use root within the container, otherwise you will encounter read/write permission related errors, see comments in `docker/docker-compose.dev.yml`.
#### Connect web to a remote backend

View File

@@ -1222,4 +1222,4 @@ Feel free to make a feature request if there's a model you want to use that we d
[huggingface-clip]: https://huggingface.co/collections/immich-app/clip-654eaefb077425890874cd07
[huggingface-multilingual-clip]: https://huggingface.co/collections/immich-app/multilingual-clip-654eb08c2382f591eeb8c2a7
[smart-search-settings]: https://my.immich.app/admin/system-settings?isOpen=machine-learning+smart-search
[job-status-page]: https://my.immich.app/admin/jobs-status
[job-status-page]: https://my.immich.app/admin/queues

View File

@@ -53,7 +53,7 @@ Version mismatches between both hosts may cause bugs and instability, so remembe
Adding a new URL to the settings is recommended over replacing the existing URL. This is because it will allow machine learning tasks to be processed successfully when the remote server is down by falling back to the local machine learning container. If you do not want machine learning tasks to be processed locally when the remote server is not available, you can instead replace the existing URL and only provide the remote container's URL. If doing this, you can remove the `immich-machine-learning` section of the local `docker-compose.yml` file to save resources, as this service will never be used.
Do note that this will mean that Smart Search and Face Detection jobs will fail to be processed when the remote instance is not available. This in turn means that tasks dependent on these features—Duplicate Detection and Facial Recognition—will not run for affected assets. If this occurs, you must manually click the _Missing_ button next to Smart Search and Face Detection in the [Job Status](http://my.immich.app/admin/jobs-status) page for the jobs to be retried.
Do note that this will mean that Smart Search and Face Detection jobs will fail to be processed when the remote instance is not available. This in turn means that tasks dependent on these features—Duplicate Detection and Facial Recognition—will not run for affected assets. If this occurs, you must manually click the _Missing_ button next to Smart Search and Face Detection in the [Job Status](http://my.immich.app/admin/queues) page for the jobs to be retried.
## Load balancing

View File

@@ -7,6 +7,7 @@
"action_common_update": "Update",
"actions": "Actions",
"active": "Active",
"active_count": "Active: {count}",
"activity": "Activity",
"activity_changed": "Activity is {enabled, select, true {enabled} other {disabled}}",
"add": "Add",
@@ -111,10 +112,9 @@
"job_not_concurrency_safe": "This job is not concurrency-safe.",
"job_settings": "Job Settings",
"job_settings_description": "Manage job concurrency",
"job_status": "Job Status",
"jobs_delayed": "{jobCount, plural, other {# delayed}}",
"jobs_failed": "{jobCount, plural, other {# failed}}",
"jobs_page_description": "Admin jobs page",
"jobs_over_time": "Jobs over time",
"library_created": "Created library: {library}",
"library_deleted": "Library deleted",
"library_details": "Library details",
@@ -277,10 +277,14 @@
"password_settings_description": "Manage password login settings",
"paths_validated_successfully": "All paths validated successfully",
"person_cleanup_job": "Person cleanup",
"queue_details": "Queue Details",
"queues": "Job Queues",
"queues_page_description": "Admin job queues page",
"quota_size_gib": "Quota Size (GiB)",
"refreshing_all_libraries": "Refreshing all libraries",
"registration": "Admin Registration",
"registration_description": "Since you are the first user on the system, you will be assigned as the Admin and are responsible for administrative tasks, and additional users will be created by you.",
"remove_failed_jobs": "Remove failed jobs",
"require_password_change_on_login": "Require user to change password on first login",
"reset_settings_to_default": "Reset settings to default",
"reset_settings_to_recent_saved": "Reset settings to the recent saved settings",
@@ -1102,6 +1106,7 @@
"external_network_sheet_info": "When not on the preferred Wi-Fi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom",
"face_unassigned": "Unassigned",
"failed": "Failed",
"failed_count": "Failed: {count}",
"failed_to_authenticate": "Failed to authenticate",
"failed_to_load_assets": "Failed to load assets",
"failed_to_load_folder": "Failed to load folder",
@@ -2209,6 +2214,7 @@
"viewer_unstack": "Un-Stack",
"visibility_changed": "Visibility changed for {count, plural, one {# person} other {# people}}",
"waiting": "Waiting",
"waiting_count": "Waiting: {count}",
"warning": "Warning",
"week": "Week",
"welcome": "Welcome",

View File

@@ -89,7 +89,10 @@ data class PlatformAsset (
val height: Long? = null,
val durationInSeconds: Long,
val orientation: Long,
val isFavorite: Boolean
val isFavorite: Boolean,
val adjustmentTime: Long? = null,
val latitude: Double? = null,
val longitude: Double? = null
)
{
companion object {
@@ -104,7 +107,10 @@ data class PlatformAsset (
val durationInSeconds = pigeonVar_list[7] as Long
val orientation = pigeonVar_list[8] as Long
val isFavorite = pigeonVar_list[9] as Boolean
return PlatformAsset(id, name, type, createdAt, updatedAt, width, height, durationInSeconds, orientation, isFavorite)
val adjustmentTime = pigeonVar_list[10] as Long?
val latitude = pigeonVar_list[11] as Double?
val longitude = pigeonVar_list[12] as Double?
return PlatformAsset(id, name, type, createdAt, updatedAt, width, height, durationInSeconds, orientation, isFavorite, adjustmentTime, latitude, longitude)
}
}
fun toList(): List<Any?> {
@@ -119,6 +125,9 @@ data class PlatformAsset (
durationInSeconds,
orientation,
isFavorite,
adjustmentTime,
latitude,
longitude,
)
}
override fun equals(other: Any?): Boolean {

View File

@@ -4,7 +4,6 @@ import android.annotation.SuppressLint
import android.content.ContentUris
import android.content.Context
import android.database.Cursor
import android.net.Uri
import android.os.Bundle
import android.provider.MediaStore
import android.util.Base64

File diff suppressed because one or more lines are too long

View File

@@ -140,6 +140,9 @@ struct PlatformAsset: Hashable {
var durationInSeconds: Int64
var orientation: Int64
var isFavorite: Bool
var adjustmentTime: Int64? = nil
var latitude: Double? = nil
var longitude: Double? = nil
// swift-format-ignore: AlwaysUseLowerCamelCase
@@ -154,6 +157,9 @@ struct PlatformAsset: Hashable {
let durationInSeconds = pigeonVar_list[7] as! Int64
let orientation = pigeonVar_list[8] as! Int64
let isFavorite = pigeonVar_list[9] as! Bool
let adjustmentTime: Int64? = nilOrValue(pigeonVar_list[10])
let latitude: Double? = nilOrValue(pigeonVar_list[11])
let longitude: Double? = nilOrValue(pigeonVar_list[12])
return PlatformAsset(
id: id,
@@ -165,7 +171,10 @@ struct PlatformAsset: Hashable {
height: height,
durationInSeconds: durationInSeconds,
orientation: orientation,
isFavorite: isFavorite
isFavorite: isFavorite,
adjustmentTime: adjustmentTime,
latitude: latitude,
longitude: longitude
)
}
func toList() -> [Any?] {
@@ -180,6 +189,9 @@ struct PlatformAsset: Hashable {
durationInSeconds,
orientation,
isFavorite,
adjustmentTime,
latitude,
longitude,
]
}
static func == (lhs: PlatformAsset, rhs: PlatformAsset) -> Bool {

View File

@@ -12,7 +12,10 @@ extension PHAsset {
height: Int64(pixelHeight),
durationInSeconds: Int64(duration),
orientation: 0,
isFavorite: isFavorite
isFavorite: isFavorite,
adjustmentTime: adjustmentTimestamp,
latitude: location?.coordinate.latitude,
longitude: location?.coordinate.longitude
)
}
@@ -23,6 +26,13 @@ extension PHAsset {
var filename: String? {
return value(forKey: "filename") as? String
}
var adjustmentTimestamp: Int64? {
if let date = value(forKey: "adjustmentTimestamp") as? Date {
return Int64(date.timeIntervalSince1970)
}
return nil
}
// This method is expected to be slow as it goes through the asset resources to fetch the originalFilename
var originalFilename: String? {

View File

@@ -5,6 +5,10 @@ class LocalAsset extends BaseAsset {
final String? remoteAssetId;
final int orientation;
final DateTime? adjustmentTime;
final double? latitude;
final double? longitude;
const LocalAsset({
required this.id,
String? remoteId,
@@ -19,6 +23,9 @@ class LocalAsset extends BaseAsset {
super.isFavorite = false,
super.livePhotoVideoId,
this.orientation = 0,
this.adjustmentTime,
this.latitude,
this.longitude,
}) : remoteAssetId = remoteId;
@override
@@ -33,6 +40,8 @@ class LocalAsset extends BaseAsset {
@override
String get heroTag => '${id}_${remoteId ?? checksum}';
bool get hasCoordinates => latitude != null && longitude != null && latitude != 0 && longitude != 0;
@override
String toString() {
return '''LocalAsset {
@@ -47,6 +56,9 @@ class LocalAsset extends BaseAsset {
remoteId: ${remoteId ?? "<NA>"}
isFavorite: $isFavorite,
orientation: $orientation,
adjustmentTime: $adjustmentTime,
latitude: ${latitude ?? "<NA>"},
longitude: ${longitude ?? "<NA>"},
}''';
}
@@ -55,11 +67,23 @@ class LocalAsset extends BaseAsset {
bool operator ==(Object other) {
if (other is! LocalAsset) return false;
if (identical(this, other)) return true;
return super == other && id == other.id && orientation == other.orientation;
return super == other &&
id == other.id &&
orientation == other.orientation &&
adjustmentTime == other.adjustmentTime &&
latitude == other.latitude &&
longitude == other.longitude;
}
@override
int get hashCode => super.hashCode ^ id.hashCode ^ remoteId.hashCode ^ orientation.hashCode;
int get hashCode =>
super.hashCode ^
id.hashCode ^
remoteId.hashCode ^
orientation.hashCode ^
adjustmentTime.hashCode ^
latitude.hashCode ^
longitude.hashCode;
LocalAsset copyWith({
String? id,
@@ -74,6 +98,9 @@ class LocalAsset extends BaseAsset {
int? durationInSeconds,
bool? isFavorite,
int? orientation,
DateTime? adjustmentTime,
double? latitude,
double? longitude,
}) {
return LocalAsset(
id: id ?? this.id,
@@ -88,6 +115,9 @@ class LocalAsset extends BaseAsset {
durationInSeconds: durationInSeconds ?? this.durationInSeconds,
isFavorite: isFavorite ?? this.isFavorite,
orientation: orientation ?? this.orientation,
adjustmentTime: adjustmentTime ?? this.adjustmentTime,
latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude,
);
}
}

View File

@@ -37,7 +37,7 @@ class ExifInfo {
String get fNumber => f == null ? "" : f!.toStringAsFixed(1);
String get focalLength => mm == null ? "" : mm!.toStringAsFixed(1);
String get focalLength => mm == null ? "" : mm!.toStringAsFixed(3);
const ExifInfo({
this.assetId,

View File

@@ -1,10 +1,8 @@
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
import 'package:immich_mobile/infrastructure/utils/exif.converter.dart';
class AssetService {
final RemoteAssetRepository _remoteAssetRepository;
@@ -58,22 +56,11 @@ class AssetService {
}
Future<double> getAspectRatio(BaseAsset asset) async {
bool isFlipped;
double? width;
double? height;
if (asset.hasRemote) {
final exif = await getExif(asset);
isFlipped = ExifDtoConverter.isOrientationFlipped(exif?.orientation);
width = asset.width?.toDouble();
height = asset.height?.toDouble();
} else if (asset is LocalAsset) {
isFlipped = CurrentPlatform.isAndroid && (asset.orientation == 90 || asset.orientation == 270);
width = asset.width?.toDouble();
height = asset.height?.toDouble();
} else {
isFlipped = false;
}
width = asset.width?.toDouble();
height = asset.height?.toDouble();
if (width == null || height == null) {
if (asset.hasRemote) {
@@ -89,10 +76,8 @@ class AssetService {
}
}
final orientedWidth = isFlipped ? height : width;
final orientedHeight = isFlipped ? width : height;
if (orientedWidth != null && orientedHeight != null && orientedHeight > 0) {
return orientedWidth / orientedHeight;
if (width != null && height != null && height > 0) {
return width / height;
}
return 1.0;

View File

@@ -286,11 +286,23 @@ class LocalSyncService {
}
bool _assetsEqual(LocalAsset a, LocalAsset b) {
return a.updatedAt.isAtSameMomentAs(b.updatedAt) &&
if (CurrentPlatform.isAndroid) {
return a.updatedAt.isAtSameMomentAs(b.updatedAt) &&
a.createdAt.isAtSameMomentAs(b.createdAt) &&
a.width == b.width &&
a.height == b.height &&
a.durationInSeconds == b.durationInSeconds;
}
final firstAdjustment = a.adjustmentTime?.millisecondsSinceEpoch ?? 0;
final secondAdjustment = b.adjustmentTime?.millisecondsSinceEpoch ?? 0;
return firstAdjustment == secondAdjustment &&
a.createdAt.isAtSameMomentAs(b.createdAt) &&
a.width == b.width &&
a.height == b.height &&
a.durationInSeconds == b.durationInSeconds;
a.durationInSeconds == b.durationInSeconds &&
a.latitude == b.latitude &&
a.longitude == b.longitude;
}
bool _albumsEqual(LocalAlbum a, LocalAlbum b) {
@@ -376,5 +388,8 @@ extension PlatformToLocalAsset on PlatformAsset {
durationInSeconds: durationInSeconds,
isFavorite: isFavorite,
orientation: orientation,
adjustmentTime: tryFromSecondsSinceEpoch(adjustmentTime, isUtc: true),
latitude: latitude,
longitude: longitude,
);
}

View File

@@ -166,5 +166,6 @@ extension RemoteExifEntityDataDomainEx on RemoteExifEntityData {
mm: focalLength?.toDouble(),
lens: lens,
isFlipped: ExifDtoConverter.isOrientationFlipped(orientation),
exposureSeconds: ExifDtoConverter.exposureTimeToSeconds(exposureTime),
);
}

View File

@@ -16,6 +16,12 @@ class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
IntColumn get orientation => integer().withDefault(const Constant(0))();
DateTimeColumn get adjustmentTime => dateTime().nullable()();
RealColumn get latitude => real().nullable()();
RealColumn get longitude => real().nullable()();
@override
Set<Column> get primaryKey => {id};
}
@@ -34,5 +40,8 @@ extension LocalAssetEntityDataDomainExtension on LocalAssetEntityData {
width: width,
remoteId: remoteId,
orientation: orientation,
adjustmentTime: adjustmentTime,
latitude: latitude,
longitude: longitude,
);
}

View File

@@ -21,6 +21,9 @@ typedef $$LocalAssetEntityTableCreateCompanionBuilder =
i0.Value<String?> checksum,
i0.Value<bool> isFavorite,
i0.Value<int> orientation,
i0.Value<DateTime?> adjustmentTime,
i0.Value<double?> latitude,
i0.Value<double?> longitude,
});
typedef $$LocalAssetEntityTableUpdateCompanionBuilder =
i1.LocalAssetEntityCompanion Function({
@@ -35,6 +38,9 @@ typedef $$LocalAssetEntityTableUpdateCompanionBuilder =
i0.Value<String?> checksum,
i0.Value<bool> isFavorite,
i0.Value<int> orientation,
i0.Value<DateTime?> adjustmentTime,
i0.Value<double?> latitude,
i0.Value<double?> longitude,
});
class $$LocalAssetEntityTableFilterComposer
@@ -101,6 +107,21 @@ class $$LocalAssetEntityTableFilterComposer
column: $table.orientation,
builder: (column) => i0.ColumnFilters(column),
);
i0.ColumnFilters<DateTime> get adjustmentTime => $composableBuilder(
column: $table.adjustmentTime,
builder: (column) => i0.ColumnFilters(column),
);
i0.ColumnFilters<double> get latitude => $composableBuilder(
column: $table.latitude,
builder: (column) => i0.ColumnFilters(column),
);
i0.ColumnFilters<double> get longitude => $composableBuilder(
column: $table.longitude,
builder: (column) => i0.ColumnFilters(column),
);
}
class $$LocalAssetEntityTableOrderingComposer
@@ -166,6 +187,21 @@ class $$LocalAssetEntityTableOrderingComposer
column: $table.orientation,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<DateTime> get adjustmentTime => $composableBuilder(
column: $table.adjustmentTime,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<double> get latitude => $composableBuilder(
column: $table.latitude,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<double> get longitude => $composableBuilder(
column: $table.longitude,
builder: (column) => i0.ColumnOrderings(column),
);
}
class $$LocalAssetEntityTableAnnotationComposer
@@ -215,6 +251,17 @@ class $$LocalAssetEntityTableAnnotationComposer
column: $table.orientation,
builder: (column) => column,
);
i0.GeneratedColumn<DateTime> get adjustmentTime => $composableBuilder(
column: $table.adjustmentTime,
builder: (column) => column,
);
i0.GeneratedColumn<double> get latitude =>
$composableBuilder(column: $table.latitude, builder: (column) => column);
i0.GeneratedColumn<double> get longitude =>
$composableBuilder(column: $table.longitude, builder: (column) => column);
}
class $$LocalAssetEntityTableTableManager
@@ -268,6 +315,9 @@ class $$LocalAssetEntityTableTableManager
i0.Value<String?> checksum = const i0.Value.absent(),
i0.Value<bool> isFavorite = const i0.Value.absent(),
i0.Value<int> orientation = const i0.Value.absent(),
i0.Value<DateTime?> adjustmentTime = const i0.Value.absent(),
i0.Value<double?> latitude = const i0.Value.absent(),
i0.Value<double?> longitude = const i0.Value.absent(),
}) => i1.LocalAssetEntityCompanion(
name: name,
type: type,
@@ -280,6 +330,9 @@ class $$LocalAssetEntityTableTableManager
checksum: checksum,
isFavorite: isFavorite,
orientation: orientation,
adjustmentTime: adjustmentTime,
latitude: latitude,
longitude: longitude,
),
createCompanionCallback:
({
@@ -294,6 +347,9 @@ class $$LocalAssetEntityTableTableManager
i0.Value<String?> checksum = const i0.Value.absent(),
i0.Value<bool> isFavorite = const i0.Value.absent(),
i0.Value<int> orientation = const i0.Value.absent(),
i0.Value<DateTime?> adjustmentTime = const i0.Value.absent(),
i0.Value<double?> latitude = const i0.Value.absent(),
i0.Value<double?> longitude = const i0.Value.absent(),
}) => i1.LocalAssetEntityCompanion.insert(
name: name,
type: type,
@@ -306,6 +362,9 @@ class $$LocalAssetEntityTableTableManager
checksum: checksum,
isFavorite: isFavorite,
orientation: orientation,
adjustmentTime: adjustmentTime,
latitude: latitude,
longitude: longitude,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
@@ -473,6 +532,39 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
requiredDuringInsert: false,
defaultValue: const i4.Constant(0),
);
static const i0.VerificationMeta _adjustmentTimeMeta =
const i0.VerificationMeta('adjustmentTime');
@override
late final i0.GeneratedColumn<DateTime> adjustmentTime =
i0.GeneratedColumn<DateTime>(
'adjustment_time',
aliasedName,
true,
type: i0.DriftSqlType.dateTime,
requiredDuringInsert: false,
);
static const i0.VerificationMeta _latitudeMeta = const i0.VerificationMeta(
'latitude',
);
@override
late final i0.GeneratedColumn<double> latitude = i0.GeneratedColumn<double>(
'latitude',
aliasedName,
true,
type: i0.DriftSqlType.double,
requiredDuringInsert: false,
);
static const i0.VerificationMeta _longitudeMeta = const i0.VerificationMeta(
'longitude',
);
@override
late final i0.GeneratedColumn<double> longitude = i0.GeneratedColumn<double>(
'longitude',
aliasedName,
true,
type: i0.DriftSqlType.double,
requiredDuringInsert: false,
);
@override
List<i0.GeneratedColumn> get $columns => [
name,
@@ -486,6 +578,9 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
checksum,
isFavorite,
orientation,
adjustmentTime,
latitude,
longitude,
];
@override
String get aliasedName => _alias ?? actualTableName;
@@ -566,6 +661,27 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
),
);
}
if (data.containsKey('adjustment_time')) {
context.handle(
_adjustmentTimeMeta,
adjustmentTime.isAcceptableOrUnknown(
data['adjustment_time']!,
_adjustmentTimeMeta,
),
);
}
if (data.containsKey('latitude')) {
context.handle(
_latitudeMeta,
latitude.isAcceptableOrUnknown(data['latitude']!, _latitudeMeta),
);
}
if (data.containsKey('longitude')) {
context.handle(
_longitudeMeta,
longitude.isAcceptableOrUnknown(data['longitude']!, _longitudeMeta),
);
}
return context;
}
@@ -624,6 +740,18 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
i0.DriftSqlType.int,
data['${effectivePrefix}orientation'],
)!,
adjustmentTime: attachedDatabase.typeMapping.read(
i0.DriftSqlType.dateTime,
data['${effectivePrefix}adjustment_time'],
),
latitude: attachedDatabase.typeMapping.read(
i0.DriftSqlType.double,
data['${effectivePrefix}latitude'],
),
longitude: attachedDatabase.typeMapping.read(
i0.DriftSqlType.double,
data['${effectivePrefix}longitude'],
),
);
}
@@ -653,6 +781,9 @@ class LocalAssetEntityData extends i0.DataClass
final String? checksum;
final bool isFavorite;
final int orientation;
final DateTime? adjustmentTime;
final double? latitude;
final double? longitude;
const LocalAssetEntityData({
required this.name,
required this.type,
@@ -665,6 +796,9 @@ class LocalAssetEntityData extends i0.DataClass
this.checksum,
required this.isFavorite,
required this.orientation,
this.adjustmentTime,
this.latitude,
this.longitude,
});
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
@@ -692,6 +826,15 @@ class LocalAssetEntityData extends i0.DataClass
}
map['is_favorite'] = i0.Variable<bool>(isFavorite);
map['orientation'] = i0.Variable<int>(orientation);
if (!nullToAbsent || adjustmentTime != null) {
map['adjustment_time'] = i0.Variable<DateTime>(adjustmentTime);
}
if (!nullToAbsent || latitude != null) {
map['latitude'] = i0.Variable<double>(latitude);
}
if (!nullToAbsent || longitude != null) {
map['longitude'] = i0.Variable<double>(longitude);
}
return map;
}
@@ -714,6 +857,9 @@ class LocalAssetEntityData extends i0.DataClass
checksum: serializer.fromJson<String?>(json['checksum']),
isFavorite: serializer.fromJson<bool>(json['isFavorite']),
orientation: serializer.fromJson<int>(json['orientation']),
adjustmentTime: serializer.fromJson<DateTime?>(json['adjustmentTime']),
latitude: serializer.fromJson<double?>(json['latitude']),
longitude: serializer.fromJson<double?>(json['longitude']),
);
}
@override
@@ -733,6 +879,9 @@ class LocalAssetEntityData extends i0.DataClass
'checksum': serializer.toJson<String?>(checksum),
'isFavorite': serializer.toJson<bool>(isFavorite),
'orientation': serializer.toJson<int>(orientation),
'adjustmentTime': serializer.toJson<DateTime?>(adjustmentTime),
'latitude': serializer.toJson<double?>(latitude),
'longitude': serializer.toJson<double?>(longitude),
};
}
@@ -748,6 +897,9 @@ class LocalAssetEntityData extends i0.DataClass
i0.Value<String?> checksum = const i0.Value.absent(),
bool? isFavorite,
int? orientation,
i0.Value<DateTime?> adjustmentTime = const i0.Value.absent(),
i0.Value<double?> latitude = const i0.Value.absent(),
i0.Value<double?> longitude = const i0.Value.absent(),
}) => i1.LocalAssetEntityData(
name: name ?? this.name,
type: type ?? this.type,
@@ -762,6 +914,11 @@ class LocalAssetEntityData extends i0.DataClass
checksum: checksum.present ? checksum.value : this.checksum,
isFavorite: isFavorite ?? this.isFavorite,
orientation: orientation ?? this.orientation,
adjustmentTime: adjustmentTime.present
? adjustmentTime.value
: this.adjustmentTime,
latitude: latitude.present ? latitude.value : this.latitude,
longitude: longitude.present ? longitude.value : this.longitude,
);
LocalAssetEntityData copyWithCompanion(i1.LocalAssetEntityCompanion data) {
return LocalAssetEntityData(
@@ -782,6 +939,11 @@ class LocalAssetEntityData extends i0.DataClass
orientation: data.orientation.present
? data.orientation.value
: this.orientation,
adjustmentTime: data.adjustmentTime.present
? data.adjustmentTime.value
: this.adjustmentTime,
latitude: data.latitude.present ? data.latitude.value : this.latitude,
longitude: data.longitude.present ? data.longitude.value : this.longitude,
);
}
@@ -798,7 +960,10 @@ class LocalAssetEntityData extends i0.DataClass
..write('id: $id, ')
..write('checksum: $checksum, ')
..write('isFavorite: $isFavorite, ')
..write('orientation: $orientation')
..write('orientation: $orientation, ')
..write('adjustmentTime: $adjustmentTime, ')
..write('latitude: $latitude, ')
..write('longitude: $longitude')
..write(')'))
.toString();
}
@@ -816,6 +981,9 @@ class LocalAssetEntityData extends i0.DataClass
checksum,
isFavorite,
orientation,
adjustmentTime,
latitude,
longitude,
);
@override
bool operator ==(Object other) =>
@@ -831,7 +999,10 @@ class LocalAssetEntityData extends i0.DataClass
other.id == this.id &&
other.checksum == this.checksum &&
other.isFavorite == this.isFavorite &&
other.orientation == this.orientation);
other.orientation == this.orientation &&
other.adjustmentTime == this.adjustmentTime &&
other.latitude == this.latitude &&
other.longitude == this.longitude);
}
class LocalAssetEntityCompanion
@@ -847,6 +1018,9 @@ class LocalAssetEntityCompanion
final i0.Value<String?> checksum;
final i0.Value<bool> isFavorite;
final i0.Value<int> orientation;
final i0.Value<DateTime?> adjustmentTime;
final i0.Value<double?> latitude;
final i0.Value<double?> longitude;
const LocalAssetEntityCompanion({
this.name = const i0.Value.absent(),
this.type = const i0.Value.absent(),
@@ -859,6 +1033,9 @@ class LocalAssetEntityCompanion
this.checksum = const i0.Value.absent(),
this.isFavorite = const i0.Value.absent(),
this.orientation = const i0.Value.absent(),
this.adjustmentTime = const i0.Value.absent(),
this.latitude = const i0.Value.absent(),
this.longitude = const i0.Value.absent(),
});
LocalAssetEntityCompanion.insert({
required String name,
@@ -872,6 +1049,9 @@ class LocalAssetEntityCompanion
this.checksum = const i0.Value.absent(),
this.isFavorite = const i0.Value.absent(),
this.orientation = const i0.Value.absent(),
this.adjustmentTime = const i0.Value.absent(),
this.latitude = const i0.Value.absent(),
this.longitude = const i0.Value.absent(),
}) : name = i0.Value(name),
type = i0.Value(type),
id = i0.Value(id);
@@ -887,6 +1067,9 @@ class LocalAssetEntityCompanion
i0.Expression<String>? checksum,
i0.Expression<bool>? isFavorite,
i0.Expression<int>? orientation,
i0.Expression<DateTime>? adjustmentTime,
i0.Expression<double>? latitude,
i0.Expression<double>? longitude,
}) {
return i0.RawValuesInsertable({
if (name != null) 'name': name,
@@ -900,6 +1083,9 @@ class LocalAssetEntityCompanion
if (checksum != null) 'checksum': checksum,
if (isFavorite != null) 'is_favorite': isFavorite,
if (orientation != null) 'orientation': orientation,
if (adjustmentTime != null) 'adjustment_time': adjustmentTime,
if (latitude != null) 'latitude': latitude,
if (longitude != null) 'longitude': longitude,
});
}
@@ -915,6 +1101,9 @@ class LocalAssetEntityCompanion
i0.Value<String?>? checksum,
i0.Value<bool>? isFavorite,
i0.Value<int>? orientation,
i0.Value<DateTime?>? adjustmentTime,
i0.Value<double?>? latitude,
i0.Value<double?>? longitude,
}) {
return i1.LocalAssetEntityCompanion(
name: name ?? this.name,
@@ -928,6 +1117,9 @@ class LocalAssetEntityCompanion
checksum: checksum ?? this.checksum,
isFavorite: isFavorite ?? this.isFavorite,
orientation: orientation ?? this.orientation,
adjustmentTime: adjustmentTime ?? this.adjustmentTime,
latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude,
);
}
@@ -969,6 +1161,15 @@ class LocalAssetEntityCompanion
if (orientation.present) {
map['orientation'] = i0.Variable<int>(orientation.value);
}
if (adjustmentTime.present) {
map['adjustment_time'] = i0.Variable<DateTime>(adjustmentTime.value);
}
if (latitude.present) {
map['latitude'] = i0.Variable<double>(latitude.value);
}
if (longitude.present) {
map['longitude'] = i0.Variable<double>(longitude.value);
}
return map;
}
@@ -985,7 +1186,10 @@ class LocalAssetEntityCompanion
..write('id: $id, ')
..write('checksum: $checksum, ')
..write('isFavorite: $isFavorite, ')
..write('orientation: $orientation')
..write('orientation: $orientation, ')
..write('adjustmentTime: $adjustmentTime, ')
..write('latitude: $latitude, ')
..write('longitude: $longitude')
..write(')'))
.toString();
}

View File

@@ -10,7 +10,6 @@ import 'package:immich_mobile/infrastructure/entities/exif.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/memory.entity.dart';
import 'package:immich_mobile/infrastructure/entities/memory_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/partner.entity.dart';
@@ -21,6 +20,7 @@ import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.d
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/stack.entity.dart';
import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.steps.dart';
@@ -95,7 +95,7 @@ class Drift extends $Drift implements IDatabaseRepository {
}
@override
int get schemaVersion => 13;
int get schemaVersion => 14;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -185,6 +185,11 @@ class Drift extends $Drift implements IDatabaseRepository {
await m.createIndex(v13.idxTrashedLocalAssetChecksum);
await m.createIndex(v13.idxTrashedLocalAssetAlbum);
},
from13To14: (m, v14) async {
await m.addColumn(v14.localAssetEntity, v14.localAssetEntity.adjustmentTime);
await m.addColumn(v14.localAssetEntity, v14.localAssetEntity.latitude);
await m.addColumn(v14.localAssetEntity, v14.localAssetEntity.longitude);
},
),
);

View File

@@ -5485,6 +5485,462 @@ i1.GeneratedColumn<String> _column_95(String aliasedName) =>
false,
type: i1.DriftSqlType.string,
);
final class Schema14 extends i0.VersionedSchema {
Schema14({required super.database}) : super(version: 14);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
userEntity,
remoteAssetEntity,
stackEntity,
localAssetEntity,
remoteAlbumEntity,
localAlbumEntity,
localAlbumAssetEntity,
idxLocalAssetChecksum,
idxRemoteAssetOwnerChecksum,
uQRemoteAssetsOwnerChecksum,
uQRemoteAssetsOwnerLibraryChecksum,
idxRemoteAssetChecksum,
authUserEntity,
userMetadataEntity,
partnerEntity,
remoteExifEntity,
remoteAlbumAssetEntity,
remoteAlbumUserEntity,
memoryEntity,
memoryAssetEntity,
personEntity,
assetFaceEntity,
storeEntity,
trashedLocalAssetEntity,
idxLatLng,
idxTrashedLocalAssetChecksum,
idxTrashedLocalAssetAlbum,
];
late final Shape20 userEntity = Shape20(
source: i0.VersionedTable(
entityName: 'user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_3,
_column_84,
_column_85,
_column_91,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape17 remoteAssetEntity = Shape17(
source: i0.VersionedTable(
entityName: 'remote_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_1,
_column_8,
_column_9,
_column_5,
_column_10,
_column_11,
_column_12,
_column_0,
_column_13,
_column_14,
_column_15,
_column_16,
_column_17,
_column_18,
_column_19,
_column_20,
_column_21,
_column_86,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape3 stackEntity = Shape3(
source: i0.VersionedTable(
entityName: 'stack_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [_column_0, _column_9, _column_5, _column_15, _column_75],
attachedDatabase: database,
),
alias: null,
);
late final Shape24 localAssetEntity = Shape24(
source: i0.VersionedTable(
entityName: 'local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_1,
_column_8,
_column_9,
_column_5,
_column_10,
_column_11,
_column_12,
_column_0,
_column_22,
_column_14,
_column_23,
_column_96,
_column_46,
_column_47,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape9 remoteAlbumEntity = Shape9(
source: i0.VersionedTable(
entityName: 'remote_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_56,
_column_9,
_column_5,
_column_15,
_column_57,
_column_58,
_column_59,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape19 localAlbumEntity = Shape19(
source: i0.VersionedTable(
entityName: 'local_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_5,
_column_31,
_column_32,
_column_90,
_column_33,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape22 localAlbumAssetEntity = Shape22(
source: i0.VersionedTable(
entityName: 'local_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_34, _column_35, _column_33],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxLocalAssetChecksum = i1.Index(
'idx_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
);
final i1.Index idxRemoteAssetOwnerChecksum = i1.Index(
'idx_remote_asset_owner_checksum',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)',
);
final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index(
'UQ_remote_assets_owner_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)',
);
final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index(
'UQ_remote_assets_owner_library_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)',
);
final i1.Index idxRemoteAssetChecksum = i1.Index(
'idx_remote_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
);
late final Shape21 authUserEntity = Shape21(
source: i0.VersionedTable(
entityName: 'auth_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_3,
_column_2,
_column_84,
_column_85,
_column_92,
_column_93,
_column_7,
_column_94,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape4 userMetadataEntity = Shape4(
source: i0.VersionedTable(
entityName: 'user_metadata_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(user_id, "key")'],
columns: [_column_25, _column_26, _column_27],
attachedDatabase: database,
),
alias: null,
);
late final Shape5 partnerEntity = Shape5(
source: i0.VersionedTable(
entityName: 'partner_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
columns: [_column_28, _column_29, _column_30],
attachedDatabase: database,
),
alias: null,
);
late final Shape8 remoteExifEntity = Shape8(
source: i0.VersionedTable(
entityName: 'remote_exif_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_36,
_column_37,
_column_38,
_column_39,
_column_40,
_column_41,
_column_11,
_column_10,
_column_42,
_column_43,
_column_44,
_column_45,
_column_46,
_column_47,
_column_48,
_column_49,
_column_50,
_column_51,
_column_52,
_column_53,
_column_54,
_column_55,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape7 remoteAlbumAssetEntity = Shape7(
source: i0.VersionedTable(
entityName: 'remote_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_36, _column_60],
attachedDatabase: database,
),
alias: null,
);
late final Shape10 remoteAlbumUserEntity = Shape10(
source: i0.VersionedTable(
entityName: 'remote_album_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(album_id, user_id)'],
columns: [_column_60, _column_25, _column_61],
attachedDatabase: database,
),
alias: null,
);
late final Shape11 memoryEntity = Shape11(
source: i0.VersionedTable(
entityName: 'memory_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_9,
_column_5,
_column_18,
_column_15,
_column_8,
_column_62,
_column_63,
_column_64,
_column_65,
_column_66,
_column_67,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape12 memoryAssetEntity = Shape12(
source: i0.VersionedTable(
entityName: 'memory_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'],
columns: [_column_36, _column_68],
attachedDatabase: database,
),
alias: null,
);
late final Shape14 personEntity = Shape14(
source: i0.VersionedTable(
entityName: 'person_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_9,
_column_5,
_column_15,
_column_1,
_column_69,
_column_71,
_column_72,
_column_73,
_column_74,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape15 assetFaceEntity = Shape15(
source: i0.VersionedTable(
entityName: 'asset_face_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_36,
_column_76,
_column_77,
_column_78,
_column_79,
_column_80,
_column_81,
_column_82,
_column_83,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape18 storeEntity = Shape18(
source: i0.VersionedTable(
entityName: 'store_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [_column_87, _column_88, _column_89],
attachedDatabase: database,
),
alias: null,
);
late final Shape23 trashedLocalAssetEntity = Shape23(
source: i0.VersionedTable(
entityName: 'trashed_local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id, album_id)'],
columns: [
_column_1,
_column_8,
_column_9,
_column_5,
_column_10,
_column_11,
_column_12,
_column_0,
_column_95,
_column_22,
_column_14,
_column_23,
],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxLatLng = i1.Index(
'idx_lat_lng',
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
);
final i1.Index idxTrashedLocalAssetChecksum = i1.Index(
'idx_trashed_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)',
);
final i1.Index idxTrashedLocalAssetAlbum = i1.Index(
'idx_trashed_local_asset_album',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)',
);
}
class Shape24 extends i0.VersionedTable {
Shape24({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get name =>
columnsByName['name']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get type =>
columnsByName['type']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<DateTime> get createdAt =>
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<DateTime> get updatedAt =>
columnsByName['updated_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<int> get width =>
columnsByName['width']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get height =>
columnsByName['height']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get durationInSeconds =>
columnsByName['duration_in_seconds']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get id =>
columnsByName['id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get checksum =>
columnsByName['checksum']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<bool> get isFavorite =>
columnsByName['is_favorite']! as i1.GeneratedColumn<bool>;
i1.GeneratedColumn<int> get orientation =>
columnsByName['orientation']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<DateTime> get adjustmentTime =>
columnsByName['adjustment_time']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<double> get latitude =>
columnsByName['latitude']! as i1.GeneratedColumn<double>;
i1.GeneratedColumn<double> get longitude =>
columnsByName['longitude']! as i1.GeneratedColumn<double>;
}
i1.GeneratedColumn<DateTime> _column_96(String aliasedName) =>
i1.GeneratedColumn<DateTime>(
'adjustment_time',
aliasedName,
true,
type: i1.DriftSqlType.dateTime,
);
i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
@@ -5498,6 +5954,7 @@ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema11 schema) from10To11,
required Future<void> Function(i1.Migrator m, Schema12 schema) from11To12,
required Future<void> Function(i1.Migrator m, Schema13 schema) from12To13,
required Future<void> Function(i1.Migrator m, Schema14 schema) from13To14,
}) {
return (currentVersion, database) async {
switch (currentVersion) {
@@ -5561,6 +6018,11 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema);
await from12To13(migrator, schema);
return 13;
case 13:
final schema = Schema14(database: database);
final migrator = i1.Migrator(database, schema);
await from13To14(migrator, schema);
return 14;
default:
throw ArgumentError.value('Unknown migration from $currentVersion');
}
@@ -5580,6 +6042,7 @@ i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema11 schema) from10To11,
required Future<void> Function(i1.Migrator m, Schema12 schema) from11To12,
required Future<void> Function(i1.Migrator m, Schema13 schema) from12To13,
required Future<void> Function(i1.Migrator m, Schema14 schema) from13To14,
}) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps(
from1To2: from1To2,
@@ -5594,5 +6057,6 @@ i1.OnUpgrade stepByStep({
from10To11: from10To11,
from11To12: from11To12,
from12To13: from12To13,
from13To14: from13To14,
),
);

View File

@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
@@ -244,7 +246,56 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
return query.map((row) => row.readTable(_db.localAssetEntity).toDto()).get();
}
Future<void> _upsertAssets(Iterable<LocalAsset> localAssets) {
Future<void> Function(Iterable<LocalAsset>) get _upsertAssets =>
CurrentPlatform.isIOS ? _upsertAssetsDarwin : _upsertAssetsAndroid;
Future<void> _upsertAssetsDarwin(Iterable<LocalAsset> localAssets) async {
if (localAssets.isEmpty) {
return Future.value();
}
// Reset checksum if asset changed
await _db.batch((batch) async {
for (final asset in localAssets) {
final companion = LocalAssetEntityCompanion(
checksum: const Value(null),
adjustmentTime: Value(asset.adjustmentTime),
);
batch.update(
_db.localAssetEntity,
companion,
where: (row) => row.id.equals(asset.id) & row.adjustmentTime.isNotExp(Variable(asset.adjustmentTime)),
);
}
});
return _db.batch((batch) async {
for (final asset in localAssets) {
final companion = LocalAssetEntityCompanion.insert(
name: asset.name,
type: asset.type,
createdAt: Value(asset.createdAt),
updatedAt: Value(asset.updatedAt),
width: Value(asset.width),
height: Value(asset.height),
durationInSeconds: Value(asset.durationInSeconds),
id: asset.id,
orientation: Value(asset.orientation),
isFavorite: Value(asset.isFavorite),
latitude: Value(asset.latitude),
longitude: Value(asset.longitude),
adjustmentTime: Value(asset.adjustmentTime),
);
batch.insert<$LocalAssetEntityTable, LocalAssetEntityData>(
_db.localAssetEntity,
companion.copyWith(checksum: const Value(null)),
onConflict: DoUpdate((old) => companion),
);
}
});
}
Future<void> _upsertAssetsAndroid(Iterable<LocalAsset> localAssets) async {
if (localAssets.isEmpty) {
return Future.value();
}
@@ -260,6 +311,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
height: Value(asset.height),
durationInSeconds: Value(asset.durationInSeconds),
id: asset.id,
checksum: const Value(null),
orientation: Value(asset.orientation),
isFavorite: Value(asset.isFavorite),
);

View File

@@ -22,6 +22,7 @@ import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/utils/exif.converter.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart' as api show AssetVisibility, AlbumUserRole, UserMetadataKey;
import 'package:openapi/api.dart' hide AssetVisibility, AlbumUserRole, UserMetadataKey;
@@ -194,6 +195,8 @@ class SyncStreamRepository extends DriftDatabaseRepository {
livePhotoVideoId: Value(asset.livePhotoVideoId),
stackId: Value(asset.stackId),
libraryId: Value(asset.libraryId),
width: Value(asset.width),
height: Value(asset.height),
);
batch.insert(
@@ -245,10 +248,21 @@ class SyncStreamRepository extends DriftDatabaseRepository {
await _db.batch((batch) {
for (final exif in data) {
int? width;
int? height;
if (ExifDtoConverter.isOrientationFlipped(exif.orientation)) {
width = exif.exifImageHeight;
height = exif.exifImageWidth;
} else {
width = exif.exifImageWidth;
height = exif.exifImageHeight;
}
batch.update(
_db.remoteAssetEntity,
RemoteAssetEntityCompanion(width: Value(exif.exifImageWidth), height: Value(exif.exifImageHeight)),
where: (row) => row.id.equals(exif.assetId),
RemoteAssetEntityCompanion(width: Value(width), height: Value(height)),
where: (row) => row.id.equals(exif.assetId) & row.width.isNull() & row.height.isNull(),
);
}
});

View File

@@ -22,7 +22,7 @@ abstract final class ExifDtoConverter {
f: dto.fNumber?.toDouble(),
mm: dto.focalLength?.toDouble(),
iso: dto.iso?.toInt(),
exposureSeconds: _exposureTimeToSeconds(dto.exposureTime),
exposureSeconds: exposureTimeToSeconds(dto.exposureTime),
);
}
@@ -36,15 +36,15 @@ abstract final class ExifDtoConverter {
return isRotated90CW || isRotated270CW;
}
static double? _exposureTimeToSeconds(String? s) {
if (s == null) {
static double? exposureTimeToSeconds(String? second) {
if (second == null) {
return null;
}
double? value = double.tryParse(s);
double? value = double.tryParse(second);
if (value != null) {
return value;
}
final parts = s.split("/");
final parts = second.split("/");
if (parts.length == 2) {
final numerator = double.tryParse(parts.firstOrNull ?? "-");
final denominator = double.tryParse(parts.lastOrNull ?? "-");

View File

@@ -49,7 +49,7 @@ class MapLocationPickerPage extends HookConsumerWidget {
var currentLatLng = LatLng(currentLocation.latitude, currentLocation.longitude);
selectedLatLng.value = currentLatLng;
await controller.value?.animateCamera(CameraUpdate.newLatLng(currentLatLng));
await controller.value?.animateCamera(CameraUpdate.newLatLngZoom(currentLatLng, 12));
}
return MapThemeOverride(
@@ -66,7 +66,10 @@ class MapLocationPickerPage extends HookConsumerWidget {
borderRadius: BorderRadius.only(bottomLeft: Radius.circular(40), bottomRight: Radius.circular(40)),
),
child: MapLibreMap(
initialCameraPosition: CameraPosition(target: initialLatLng, zoom: 12),
initialCameraPosition: CameraPosition(
target: initialLatLng,
zoom: (initialLatLng.latitude == 0 && initialLatLng.longitude == 0) ? 1 : 12,
),
styleString: style,
onMapCreated: (mapController) => controller.value = mapController,
onStyleLoadedCallback: onStyleLoaded,

View File

@@ -41,6 +41,9 @@ class PlatformAsset {
required this.durationInSeconds,
required this.orientation,
required this.isFavorite,
this.adjustmentTime,
this.latitude,
this.longitude,
});
String id;
@@ -63,8 +66,28 @@ class PlatformAsset {
bool isFavorite;
int? adjustmentTime;
double? latitude;
double? longitude;
List<Object?> _toList() {
return <Object?>[id, name, type, createdAt, updatedAt, width, height, durationInSeconds, orientation, isFavorite];
return <Object?>[
id,
name,
type,
createdAt,
updatedAt,
width,
height,
durationInSeconds,
orientation,
isFavorite,
adjustmentTime,
latitude,
longitude,
];
}
Object encode() {
@@ -84,6 +107,9 @@ class PlatformAsset {
durationInSeconds: result[7]! as int,
orientation: result[8]! as int,
isFavorite: result[9]! as bool,
adjustmentTime: result[10] as int?,
latitude: result[11] as double?,
longitude: result[12] as double?,
);
}

View File

@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
@RoutePage()
@@ -129,6 +130,15 @@ class _AssetPropertiesSectionState extends ConsumerState<_AssetPropertiesSection
properties.insert(4, _PropertyItem(label: 'Orientation', value: asset.orientation.toString()));
final albums = await ref.read(assetServiceProvider).getSourceAlbums(asset.id);
properties.add(_PropertyItem(label: 'Album', value: albums.map((a) => a.name).join(', ')));
if (CurrentPlatform.isIOS) {
properties.add(_PropertyItem(label: 'Adjustment Time', value: asset.adjustmentTime?.toString()));
}
properties.add(
_PropertyItem(
label: 'GPS Coordinates',
value: asset.hasCoordinates ? '${asset.latitude}, ${asset.longitude}' : null,
),
);
}
Future<void> _addRemoteAssetProperties(RemoteAsset asset) async {

View File

@@ -251,8 +251,8 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
color: context.textTheme.labelLarge?.color,
),
subtitle: _getFileInfo(asset, exifInfo),
subtitleStyle: context.textTheme.bodyMedium?.copyWith(
color: context.textTheme.bodyMedium?.color?.withAlpha(155),
subtitleStyle: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
),
);
},
@@ -268,8 +268,8 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
color: context.textTheme.labelLarge?.color,
),
subtitle: _getFileInfo(asset, exifInfo),
subtitleStyle: context.textTheme.bodyMedium?.copyWith(
color: context.textTheme.bodyMedium?.color?.withAlpha(155),
subtitleStyle: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
),
);
}
@@ -280,7 +280,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
// Asset Date and Time
SheetTile(
title: _getDateTime(context, asset, exifInfo),
titleStyle: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
titleStyle: context.textTheme.labelLarge,
trailing: asset.hasRemote && isOwner ? const Icon(Icons.edit, size: 18) : null,
onTap: asset.hasRemote && isOwner ? () async => await _editDateTime(context, ref) : null,
),
@@ -289,7 +289,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
const SheetLocationDetails(),
// Details header
SheetTile(
title: 'exif_bottom_sheet_details'.t(context: context),
title: 'details'.t(context: context).toUpperCase(),
titleStyle: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
fontWeight: FontWeight.w600,
@@ -298,29 +298,33 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
// File info
buildFileInfoTile(),
// Camera info
if (cameraTitle != null)
if (cameraTitle != null) ...[
const SizedBox(height: 16),
SheetTile(
title: cameraTitle,
titleStyle: context.textTheme.labelLarge,
leading: Icon(Icons.camera_alt_outlined, size: 24, color: context.textTheme.labelLarge?.color),
subtitle: _getCameraInfoSubtitle(exifInfo),
subtitleStyle: context.textTheme.bodyMedium?.copyWith(
color: context.textTheme.bodyMedium?.color?.withAlpha(155),
subtitleStyle: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
),
),
],
// Lens info
if (lensTitle != null)
if (lensTitle != null) ...[
const SizedBox(height: 16),
SheetTile(
title: lensTitle,
titleStyle: context.textTheme.labelLarge,
leading: Icon(Icons.camera_outlined, size: 24, color: context.textTheme.labelLarge?.color),
subtitle: _getLensInfoSubtitle(exifInfo),
subtitleStyle: context.textTheme.bodyMedium?.copyWith(
color: context.textTheme.bodyMedium?.color?.withAlpha(155),
subtitleStyle: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
),
),
],
// Appears in (Albums)
_buildAppearsInList(ref, context),
Padding(padding: const EdgeInsets.only(top: 16.0), child: _buildAppearsInList(ref, context)),
// padding at the bottom to avoid cut-off
const SizedBox(height: 100),
],

View File

@@ -78,7 +78,7 @@ class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SheetTile(
title: 'exif_bottom_sheet_location'.t(context: context),
title: 'location'.t(context: context).toUpperCase(),
titleStyle: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
fontWeight: FontWeight.w600,
@@ -102,7 +102,7 @@ class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> {
Text(
coordinates,
style: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(150),
color: context.textTheme.labelMedium?.color?.withAlpha(200),
),
),
],

View File

@@ -46,7 +46,7 @@ class SheetTile extends ConsumerWidget {
} else {
titleWidget = Container(
width: double.infinity,
padding: const EdgeInsets.only(left: 15),
padding: const EdgeInsets.only(left: 15, right: 15),
child: Text(title, style: titleStyle),
);
}

View File

@@ -4,6 +4,7 @@ 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/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/models/backup/backup_state.model.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
@@ -150,7 +151,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
try {
bool syncSuccess = false;
await Future.wait([
_safeRun(backgroundManager.syncLocal(), "syncLocal"),
_safeRun(backgroundManager.syncLocal(full: CurrentPlatform.isAndroid ? true : false), "syncLocal"),
_safeRun(backgroundManager.syncRemote().then((success) => syncSuccess = success), "syncRemote"),
]);
if (syncSuccess) {

View File

@@ -81,7 +81,7 @@ Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
}
if (version < 19 && Store.isBetaTimelineEnabled) {
if (!await _populateUpdatedAtTime(drift)) {
if (!await _populateLocalAssetTime(drift)) {
return;
}
}
@@ -229,7 +229,7 @@ Future<void> _migrateDeviceAsset(Isar db) async {
});
}
Future<bool> _populateUpdatedAtTime(Drift db) async {
Future<bool> _populateLocalAssetTime(Drift db) async {
try {
final nativeApi = NativeSyncApi();
final albums = await nativeApi.getAlbums();
@@ -240,6 +240,9 @@ Future<bool> _populateUpdatedAtTime(Drift db) async {
batch.update(
db.localAssetEntity,
LocalAssetEntityCompanion(
longitude: Value(asset.longitude),
latitude: Value(asset.latitude),
adjustmentTime: Value(tryFromSecondsSinceEpoch(asset.adjustmentTime, isUtc: true)),
updatedAt: Value(tryFromSecondsSinceEpoch(asset.updatedAt, isUtc: true) ?? DateTime.timestamp()),
),
where: (t) => t.id.equals(asset.id),
@@ -250,7 +253,7 @@ Future<bool> _populateUpdatedAtTime(Drift db) async {
return true;
} catch (error) {
dPrint(() => "[MIGRATION] Error while populating updatedAt time: $error");
dPrint(() => "[MIGRATION] Error while populating asset time: $error");
return false;
}
}

View File

@@ -23,6 +23,7 @@ class AssetResponseDto {
required this.fileCreatedAt,
required this.fileModifiedAt,
required this.hasMetadata,
required this.height,
required this.id,
required this.isArchived,
required this.isFavorite,
@@ -45,6 +46,7 @@ class AssetResponseDto {
this.unassignedFaces = const [],
required this.updatedAt,
required this.visibility,
required this.width,
});
/// base64 encoded sha1 hash
@@ -77,6 +79,8 @@ class AssetResponseDto {
bool hasMetadata;
num? height;
String id;
bool isArchived;
@@ -141,6 +145,8 @@ class AssetResponseDto {
AssetVisibility visibility;
num? width;
@override
bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto &&
other.checksum == checksum &&
@@ -153,6 +159,7 @@ class AssetResponseDto {
other.fileCreatedAt == fileCreatedAt &&
other.fileModifiedAt == fileModifiedAt &&
other.hasMetadata == hasMetadata &&
other.height == height &&
other.id == id &&
other.isArchived == isArchived &&
other.isFavorite == isFavorite &&
@@ -174,7 +181,8 @@ class AssetResponseDto {
other.type == type &&
_deepEquality.equals(other.unassignedFaces, unassignedFaces) &&
other.updatedAt == updatedAt &&
other.visibility == visibility;
other.visibility == visibility &&
other.width == width;
@override
int get hashCode =>
@@ -189,6 +197,7 @@ class AssetResponseDto {
(fileCreatedAt.hashCode) +
(fileModifiedAt.hashCode) +
(hasMetadata.hashCode) +
(height == null ? 0 : height!.hashCode) +
(id.hashCode) +
(isArchived.hashCode) +
(isFavorite.hashCode) +
@@ -210,10 +219,11 @@ class AssetResponseDto {
(type.hashCode) +
(unassignedFaces.hashCode) +
(updatedAt.hashCode) +
(visibility.hashCode);
(visibility.hashCode) +
(width == null ? 0 : width!.hashCode);
@override
String toString() => 'AssetResponseDto[checksum=$checksum, createdAt=$createdAt, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, stack=$stack, tags=$tags, thumbhash=$thumbhash, type=$type, unassignedFaces=$unassignedFaces, updatedAt=$updatedAt, visibility=$visibility]';
String toString() => 'AssetResponseDto[checksum=$checksum, createdAt=$createdAt, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, height=$height, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, stack=$stack, tags=$tags, thumbhash=$thumbhash, type=$type, unassignedFaces=$unassignedFaces, updatedAt=$updatedAt, visibility=$visibility, width=$width]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -235,6 +245,11 @@ class AssetResponseDto {
json[r'fileCreatedAt'] = this.fileCreatedAt.toUtc().toIso8601String();
json[r'fileModifiedAt'] = this.fileModifiedAt.toUtc().toIso8601String();
json[r'hasMetadata'] = this.hasMetadata;
if (this.height != null) {
json[r'height'] = this.height;
} else {
// json[r'height'] = null;
}
json[r'id'] = this.id;
json[r'isArchived'] = this.isArchived;
json[r'isFavorite'] = this.isFavorite;
@@ -285,6 +300,11 @@ class AssetResponseDto {
json[r'unassignedFaces'] = this.unassignedFaces;
json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String();
json[r'visibility'] = this.visibility;
if (this.width != null) {
json[r'width'] = this.width;
} else {
// json[r'width'] = null;
}
return json;
}
@@ -307,6 +327,9 @@ class AssetResponseDto {
fileCreatedAt: mapDateTime(json, r'fileCreatedAt', r'')!,
fileModifiedAt: mapDateTime(json, r'fileModifiedAt', r'')!,
hasMetadata: mapValueOfType<bool>(json, r'hasMetadata')!,
height: json[r'height'] == null
? null
: num.parse('${json[r'height']}'),
id: mapValueOfType<String>(json, r'id')!,
isArchived: mapValueOfType<bool>(json, r'isArchived')!,
isFavorite: mapValueOfType<bool>(json, r'isFavorite')!,
@@ -329,6 +352,9 @@ class AssetResponseDto {
unassignedFaces: AssetFaceWithoutPersonResponseDto.listFromJson(json[r'unassignedFaces']),
updatedAt: mapDateTime(json, r'updatedAt', r'')!,
visibility: AssetVisibility.fromJson(json[r'visibility'])!,
width: json[r'width'] == null
? null
: num.parse('${json[r'width']}'),
);
}
return null;
@@ -384,6 +410,7 @@ class AssetResponseDto {
'fileCreatedAt',
'fileModifiedAt',
'hasMetadata',
'height',
'id',
'isArchived',
'isFavorite',
@@ -397,6 +424,7 @@ class AssetResponseDto {
'type',
'updatedAt',
'visibility',
'width',
};
}

View File

@@ -18,6 +18,7 @@ class SyncAssetV1 {
required this.duration,
required this.fileCreatedAt,
required this.fileModifiedAt,
required this.height,
required this.id,
required this.isFavorite,
required this.libraryId,
@@ -29,6 +30,7 @@ class SyncAssetV1 {
required this.thumbhash,
required this.type,
required this.visibility,
required this.width,
});
String checksum;
@@ -41,6 +43,8 @@ class SyncAssetV1 {
DateTime? fileModifiedAt;
int? height;
String id;
bool isFavorite;
@@ -63,6 +67,8 @@ class SyncAssetV1 {
AssetVisibility visibility;
int? width;
@override
bool operator ==(Object other) => identical(this, other) || other is SyncAssetV1 &&
other.checksum == checksum &&
@@ -70,6 +76,7 @@ class SyncAssetV1 {
other.duration == duration &&
other.fileCreatedAt == fileCreatedAt &&
other.fileModifiedAt == fileModifiedAt &&
other.height == height &&
other.id == id &&
other.isFavorite == isFavorite &&
other.libraryId == libraryId &&
@@ -80,7 +87,8 @@ class SyncAssetV1 {
other.stackId == stackId &&
other.thumbhash == thumbhash &&
other.type == type &&
other.visibility == visibility;
other.visibility == visibility &&
other.width == width;
@override
int get hashCode =>
@@ -90,6 +98,7 @@ class SyncAssetV1 {
(duration == null ? 0 : duration!.hashCode) +
(fileCreatedAt == null ? 0 : fileCreatedAt!.hashCode) +
(fileModifiedAt == null ? 0 : fileModifiedAt!.hashCode) +
(height == null ? 0 : height!.hashCode) +
(id.hashCode) +
(isFavorite.hashCode) +
(libraryId == null ? 0 : libraryId!.hashCode) +
@@ -100,10 +109,11 @@ class SyncAssetV1 {
(stackId == null ? 0 : stackId!.hashCode) +
(thumbhash == null ? 0 : thumbhash!.hashCode) +
(type.hashCode) +
(visibility.hashCode);
(visibility.hashCode) +
(width == null ? 0 : width!.hashCode);
@override
String toString() => 'SyncAssetV1[checksum=$checksum, deletedAt=$deletedAt, duration=$duration, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, id=$id, isFavorite=$isFavorite, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, ownerId=$ownerId, stackId=$stackId, thumbhash=$thumbhash, type=$type, visibility=$visibility]';
String toString() => 'SyncAssetV1[checksum=$checksum, deletedAt=$deletedAt, duration=$duration, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, height=$height, id=$id, isFavorite=$isFavorite, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, ownerId=$ownerId, stackId=$stackId, thumbhash=$thumbhash, type=$type, visibility=$visibility, width=$width]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -127,6 +137,11 @@ class SyncAssetV1 {
json[r'fileModifiedAt'] = this.fileModifiedAt!.toUtc().toIso8601String();
} else {
// json[r'fileModifiedAt'] = null;
}
if (this.height != null) {
json[r'height'] = this.height;
} else {
// json[r'height'] = null;
}
json[r'id'] = this.id;
json[r'isFavorite'] = this.isFavorite;
@@ -159,6 +174,11 @@ class SyncAssetV1 {
}
json[r'type'] = this.type;
json[r'visibility'] = this.visibility;
if (this.width != null) {
json[r'width'] = this.width;
} else {
// json[r'width'] = null;
}
return json;
}
@@ -176,6 +196,7 @@ class SyncAssetV1 {
duration: mapValueOfType<String>(json, r'duration'),
fileCreatedAt: mapDateTime(json, r'fileCreatedAt', r''),
fileModifiedAt: mapDateTime(json, r'fileModifiedAt', r''),
height: mapValueOfType<int>(json, r'height'),
id: mapValueOfType<String>(json, r'id')!,
isFavorite: mapValueOfType<bool>(json, r'isFavorite')!,
libraryId: mapValueOfType<String>(json, r'libraryId'),
@@ -187,6 +208,7 @@ class SyncAssetV1 {
thumbhash: mapValueOfType<String>(json, r'thumbhash'),
type: AssetTypeEnum.fromJson(json[r'type'])!,
visibility: AssetVisibility.fromJson(json[r'visibility'])!,
width: mapValueOfType<int>(json, r'width'),
);
}
return null;
@@ -239,6 +261,7 @@ class SyncAssetV1 {
'duration',
'fileCreatedAt',
'fileModifiedAt',
'height',
'id',
'isFavorite',
'libraryId',
@@ -250,6 +273,7 @@ class SyncAssetV1 {
'thumbhash',
'type',
'visibility',
'width',
};
}

View File

@@ -27,6 +27,10 @@ class PlatformAsset {
final int orientation;
final bool isFavorite;
final int? adjustmentTime;
final double? latitude;
final double? longitude;
const PlatformAsset({
required this.id,
required this.name,
@@ -38,6 +42,9 @@ class PlatformAsset {
this.durationInSeconds = 0,
this.orientation = 0,
this.isFavorite = false,
this.adjustmentTime,
this.latitude,
this.longitude,
});
}

View File

@@ -0,0 +1,185 @@
import 'package:drift/drift.dart' as drift;
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
import 'package:openapi/api.dart';
SyncUserV1 _createUser({String id = 'user-1'}) {
return SyncUserV1(
id: id,
name: 'Test User',
email: 'test@test.com',
deletedAt: null,
avatarColor: null,
hasProfileImage: false,
profileChangedAt: DateTime(2024, 1, 1),
);
}
SyncAssetV1 _createAsset({
required String id,
required String checksum,
required String fileName,
String ownerId = 'user-1',
int? width,
int? height,
}) {
return SyncAssetV1(
id: id,
checksum: checksum,
originalFileName: fileName,
type: AssetTypeEnum.IMAGE,
ownerId: ownerId,
isFavorite: false,
fileCreatedAt: DateTime(2024, 1, 1),
fileModifiedAt: DateTime(2024, 1, 1),
localDateTime: DateTime(2024, 1, 1),
visibility: AssetVisibility.timeline,
width: width,
height: height,
deletedAt: null,
duration: null,
libraryId: null,
livePhotoVideoId: null,
stackId: null,
thumbhash: null,
);
}
SyncAssetExifV1 _createExif({
required String assetId,
required int width,
required int height,
required String orientation,
}) {
return SyncAssetExifV1(
assetId: assetId,
exifImageWidth: width,
exifImageHeight: height,
orientation: orientation,
city: null,
country: null,
dateTimeOriginal: null,
description: null,
exposureTime: null,
fNumber: null,
fileSizeInByte: null,
focalLength: null,
fps: null,
iso: null,
latitude: null,
lensModel: null,
longitude: null,
make: null,
model: null,
modifyDate: null,
profileDescription: null,
projectionType: null,
rating: null,
state: null,
timeZone: null,
);
}
void main() {
late Drift db;
late SyncStreamRepository sut;
setUp(() async {
db = Drift(drift.DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
sut = SyncStreamRepository(db);
});
tearDown(() async {
await db.close();
});
group('SyncStreamRepository - Dimension swapping based on orientation', () {
test('swaps dimensions for asset with rotated orientation', () async {
final flippedOrientations = ['5', '6', '7', '8', '90', '-90'];
for (final orientation in flippedOrientations) {
final assetId = 'asset-$orientation-degrees';
await sut.updateUsersV1([_createUser()]);
final asset = _createAsset(
id: assetId,
checksum: 'checksum-$orientation',
fileName: 'rotated_$orientation.jpg',
);
await sut.updateAssetsV1([asset]);
final exif = _createExif(
assetId: assetId,
width: 1920,
height: 1080,
orientation: orientation, // EXIF orientation value for 90 degrees CW
);
await sut.updateAssetsExifV1([exif]);
final query = db.remoteAssetEntity.select()..where((tbl) => tbl.id.equals(assetId));
final result = await query.getSingle();
expect(result.width, equals(1080));
expect(result.height, equals(1920));
}
});
test('does not swap dimensions for asset with normal orientation', () async {
final nonFlippedOrientations = ['1', '2', '3', '4'];
for (final orientation in nonFlippedOrientations) {
final assetId = 'asset-$orientation-degrees';
await sut.updateUsersV1([_createUser()]);
final asset = _createAsset(id: assetId, checksum: 'checksum-$orientation', fileName: 'normal_$orientation.jpg');
await sut.updateAssetsV1([asset]);
final exif = _createExif(
assetId: assetId,
width: 1920,
height: 1080,
orientation: orientation, // EXIF orientation value for normal
);
await sut.updateAssetsExifV1([exif]);
final query = db.remoteAssetEntity.select()..where((tbl) => tbl.id.equals(assetId));
final result = await query.getSingle();
expect(result.width, equals(1920));
expect(result.height, equals(1080));
}
});
test('does not update dimensions if asset already has width and height', () async {
const assetId = 'asset-with-dimensions';
const existingWidth = 1920;
const existingHeight = 1080;
const exifWidth = 3840;
const exifHeight = 2160;
await sut.updateUsersV1([_createUser()]);
final asset = _createAsset(
id: assetId,
checksum: 'checksum-with-dims',
fileName: 'with_dimensions.jpg',
width: existingWidth,
height: existingHeight,
);
await sut.updateAssetsV1([asset]);
final exif = _createExif(assetId: assetId, width: exifWidth, height: exifHeight, orientation: '6');
await sut.updateAssetsExifV1([exif]);
// Verify the asset still has original dimensions (not updated from EXIF)
final query = db.remoteAssetEntity.select()..where((tbl) => tbl.id.equals(assetId));
final result = await query.getSingle();
expect(result.width, equals(existingWidth), reason: 'Width should remain as originally set');
expect(result.height, equals(existingHeight), reason: 'Height should remain as originally set');
});
});
}

View File

@@ -1,4 +1,3 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/domain/services/asset.service.dart';
@@ -22,42 +21,6 @@ void main() {
});
group('getAspectRatio', () {
test('flips dimensions on Android for 90° and 270° orientations', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
for (final orientation in [90, 270]) {
final localAsset = TestUtils.createLocalAsset(
id: 'local-$orientation',
width: 1920,
height: 1080,
orientation: orientation,
);
final result = await sut.getAspectRatio(localAsset);
expect(result, 1080 / 1920, reason: 'Orientation $orientation should flip on Android');
}
});
test('does not flip dimensions on iOS regardless of orientation', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
for (final orientation in [0, 90, 270]) {
final localAsset = TestUtils.createLocalAsset(
id: 'local-$orientation',
width: 1920,
height: 1080,
orientation: orientation,
);
final result = await sut.getAspectRatio(localAsset);
expect(result, 1920 / 1080, reason: 'iOS should never flip dimensions');
}
});
test('fetches dimensions from remote repository when missing from asset', () async {
final remoteAsset = TestUtils.createRemoteAsset(id: 'remote-1', width: null, height: null);
@@ -112,54 +75,23 @@ void main() {
expect(result, 1.0);
});
test('handles local asset with remoteId and uses exif from remote', () async {
test('handles local asset with remoteId and uses remote dimensions', () async {
final localAsset = TestUtils.createLocalAsset(
id: 'local-1',
remoteId: 'remote-1',
width: 1920,
height: 1080,
width: null,
height: null,
orientation: 0,
);
final exif = const ExifInfo(orientation: '6');
when(() => mockRemoteAssetRepository.getExif('remote-1')).thenAnswer((_) async => exif);
when(
() => mockRemoteAssetRepository.get('remote-1'),
).thenAnswer((_) async => TestUtils.createRemoteAsset(id: 'remote-1', width: 1920, height: 1080));
final result = await sut.getAspectRatio(localAsset);
verify(() => mockRemoteAssetRepository.get('remote-1')).called(1);
expect(result, 1080 / 1920);
});
test('handles various flipped EXIF orientations correctly', () async {
final flippedOrientations = ['5', '6', '7', '8', '90', '-90'];
for (final orientation in flippedOrientations) {
final remoteAsset = TestUtils.createRemoteAsset(id: 'remote-$orientation', width: 1920, height: 1080);
final exif = ExifInfo(orientation: orientation);
when(() => mockRemoteAssetRepository.getExif('remote-$orientation')).thenAnswer((_) async => exif);
final result = await sut.getAspectRatio(remoteAsset);
expect(result, 1080 / 1920, reason: 'Orientation $orientation should flip dimensions');
}
});
test('handles various non-flipped EXIF orientations correctly', () async {
final nonFlippedOrientations = ['1', '2', '3', '4'];
for (final orientation in nonFlippedOrientations) {
final remoteAsset = TestUtils.createRemoteAsset(id: 'remote-$orientation', width: 1920, height: 1080);
final exif = ExifInfo(orientation: orientation);
when(() => mockRemoteAssetRepository.getExif('remote-$orientation')).thenAnswer((_) async => exif);
final result = await sut.getAspectRatio(remoteAsset);
expect(result, 1920 / 1080, reason: 'Orientation $orientation should NOT flip dimensions');
}
expect(result, 1920 / 1080);
});
});
}

View File

@@ -16,6 +16,7 @@ import 'schema_v10.dart' as v10;
import 'schema_v11.dart' as v11;
import 'schema_v12.dart' as v12;
import 'schema_v13.dart' as v13;
import 'schema_v14.dart' as v14;
class GeneratedHelper implements SchemaInstantiationHelper {
@override
@@ -47,10 +48,12 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v12.DatabaseAtV12(db);
case 13:
return v13.DatabaseAtV13(db);
case 14:
return v14.DatabaseAtV14(db);
default:
throw MissingSchemaException(version, versions);
}
}
static const versions = const [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13];
static const versions = const [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14];
}

File diff suppressed because it is too large Load Diff

View File

@@ -94,25 +94,11 @@ abstract final class SyncStreamStub {
required String ack,
DateTime? trashedAt,
}) {
return _assetV1(
id: id,
checksum: checksum,
deletedAt: trashedAt ?? DateTime(2025, 1, 1),
ack: ack,
);
return _assetV1(id: id, checksum: checksum, deletedAt: trashedAt ?? DateTime(2025, 1, 1), ack: ack);
}
static SyncEvent assetModified({
required String id,
required String checksum,
required String ack,
}) {
return _assetV1(
id: id,
checksum: checksum,
deletedAt: null,
ack: ack,
);
static SyncEvent assetModified({required String id, required String checksum, required String ack}) {
return _assetV1(id: id, checksum: checksum, deletedAt: null, ack: ack);
}
static SyncEvent _assetV1({
@@ -140,6 +126,8 @@ abstract final class SyncStreamStub {
thumbhash: null,
type: AssetTypeEnum.IMAGE,
visibility: AssetVisibility.timeline,
width: null,
height: null,
),
ack: ack,
);

View File

@@ -45,5 +45,17 @@ void main() {
addDefault(value, keys, defaultValue);
expect(value['alpha']['beta'], 'gamma');
});
test('addDefault with null', () {
dynamic value = jsonDecode("""
{
"download": {
"archiveSize": 4294967296,
"includeEmbeddedVideos": false
}
}
""");
expect(value['download']['unknownKey'], isNull);
});
});
}

View File

@@ -15706,6 +15706,10 @@
"hasMetadata": {
"type": "boolean"
},
"height": {
"nullable": true,
"type": "number"
},
"id": {
"type": "string"
},
@@ -15826,6 +15830,10 @@
"$ref": "#/components/schemas/AssetVisibility"
}
]
},
"width": {
"nullable": true,
"type": "number"
}
},
"required": [
@@ -15837,6 +15845,7 @@
"fileCreatedAt",
"fileModifiedAt",
"hasMetadata",
"height",
"id",
"isArchived",
"isFavorite",
@@ -15849,7 +15858,8 @@
"thumbhash",
"type",
"updatedAt",
"visibility"
"visibility",
"width"
],
"type": "object"
},
@@ -20624,6 +20634,10 @@
"nullable": true,
"type": "string"
},
"height": {
"nullable": true,
"type": "integer"
},
"id": {
"type": "string"
},
@@ -20670,6 +20684,10 @@
"$ref": "#/components/schemas/AssetVisibility"
}
]
},
"width": {
"nullable": true,
"type": "integer"
}
},
"required": [
@@ -20678,6 +20696,7 @@
"duration",
"fileCreatedAt",
"fileModifiedAt",
"height",
"id",
"isFavorite",
"libraryId",
@@ -20688,7 +20707,8 @@
"stackId",
"thumbhash",
"type",
"visibility"
"visibility",
"width"
],
"type": "object"
},

View File

@@ -349,6 +349,7 @@ export type AssetResponseDto = {
/** The UTC timestamp when the file was last modified on the filesystem. This reflects the last time the physical file was changed, which may be different from when the photo was originally taken. */
fileModifiedAt: string;
hasMetadata: boolean;
height: number | null;
id: string;
isArchived: boolean;
isFavorite: boolean;
@@ -373,6 +374,7 @@ export type AssetResponseDto = {
/** The UTC timestamp when the asset record was last updated in the database. This is automatically maintained by the database and reflects when any field in the asset was last modified. */
updatedAt: string;
visibility: AssetVisibility;
width: number | null;
};
export type ContributorCountResponseDto = {
assetCount: number;

8
pnpm-lock.yaml generated
View File

@@ -818,6 +818,9 @@ importers:
thumbhash:
specifier: ^0.1.1
version: 0.1.1
uplot:
specifier: ^1.6.32
version: 1.6.32
devDependencies:
'@eslint/js':
specifier: ^9.36.0
@@ -11276,6 +11279,9 @@ packages:
resolution: {integrity: sha512-EDxhTEVPZZRLWYcJ4ZXjGFN0oP7qYvbXWzEgRm/Yql4dHX5wDbvh89YHP6PK1lzZJYrMtXUuZZz8XGK+U6U1og==}
engines: {node: '>=14.16'}
uplot@1.6.32:
resolution: {integrity: sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==}
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
@@ -24536,6 +24542,8 @@ snapshots:
semver-diff: 4.0.0
xdg-basedir: 5.1.0
uplot@1.6.32: {}
uri-js@4.4.1:
dependencies:
punycode: 2.3.1

View File

@@ -340,6 +340,8 @@ export const columns = {
'asset.originalPath',
'asset.ownerId',
'asset.type',
'asset.width',
'asset.height',
],
assetFiles: ['asset_file.id', 'asset_file.path', 'asset_file.type'],
authUser: ['user.id', 'user.name', 'user.email', 'user.isAdmin', 'user.quotaUsageInBytes', 'user.quotaSizeInBytes'],
@@ -390,6 +392,8 @@ export const columns = {
'asset.livePhotoVideoId',
'asset.stackId',
'asset.libraryId',
'asset.width',
'asset.height',
],
syncAlbumUser: ['album_user.albumId as albumId', 'album_user.userId as userId', 'album_user.role'],
syncStack: ['stack.id', 'stack.createdAt', 'stack.updatedAt', 'stack.primaryAssetId', 'stack.ownerId'],

View File

@@ -34,6 +34,8 @@ export class SanitizedAssetResponseDto {
duration!: string;
livePhotoVideoId?: string | null;
hasMetadata!: boolean;
width!: number | null;
height!: number | null;
}
export class AssetResponseDto extends SanitizedAssetResponseDto {
@@ -129,6 +131,8 @@ export type MapAsset = {
tags?: Tag[];
thumbhash: Buffer<ArrayBufferLike> | null;
type: AssetType;
width: number | null;
height: number | null;
};
export class AssetStackResponseDto {
@@ -190,6 +194,8 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset
duration: entity.duration ?? '0:00:00.00000',
livePhotoVideoId: entity.livePhotoVideoId,
hasMetadata: false,
width: entity.width,
height: entity.height,
};
return sanitizedAssetResponse as AssetResponseDto;
}
@@ -227,5 +233,7 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset
hasMetadata: true,
duplicateId: entity.duplicateId,
resized: true,
width: entity.width,
height: entity.height,
};
}

View File

@@ -118,6 +118,10 @@ export class SyncAssetV1 {
livePhotoVideoId!: string | null;
stackId!: string | null;
libraryId!: string | null;
@ApiProperty({ type: 'integer' })
width!: number | null;
@ApiProperty({ type: 'integer' })
height!: number | null;
}
@ExtraModel()

View File

@@ -369,6 +369,7 @@ select
"asset"."livePhotoVideoId",
"asset"."encodedVideoPath",
"asset"."originalPath",
"asset"."isOffline",
to_json("asset_exif") as "exifInfo",
(
select

View File

@@ -345,14 +345,10 @@ with
"asset_exif"."projectionType",
coalesce(
case
when asset_exif."exifImageHeight" = 0
or asset_exif."exifImageWidth" = 0 then 1
when "asset_exif"."orientation" in ('5', '6', '7', '8', '-90', '90') then round(
asset_exif."exifImageHeight"::numeric / asset_exif."exifImageWidth"::numeric,
3
)
when asset."height" = 0
or asset."width" = 0 then 1
else round(
asset_exif."exifImageWidth"::numeric / asset_exif."exifImageHeight"::numeric,
asset."width"::numeric / asset."height"::numeric,
3
)
end,

View File

@@ -69,6 +69,8 @@ select
"asset"."livePhotoVideoId",
"asset"."stackId",
"asset"."libraryId",
"asset"."width",
"asset"."height",
"album_asset"."updateId"
from
"album_asset" as "album_asset"
@@ -99,6 +101,8 @@ select
"asset"."livePhotoVideoId",
"asset"."stackId",
"asset"."libraryId",
"asset"."width",
"asset"."height",
"asset"."updateId"
from
"asset" as "asset"
@@ -134,7 +138,9 @@ select
"asset"."duration",
"asset"."livePhotoVideoId",
"asset"."stackId",
"asset"."libraryId"
"asset"."libraryId",
"asset"."width",
"asset"."height"
from
"album_asset" as "album_asset"
inner join "asset" on "asset"."id" = "album_asset"."assetId"
@@ -448,6 +454,8 @@ select
"asset"."livePhotoVideoId",
"asset"."stackId",
"asset"."libraryId",
"asset"."width",
"asset"."height",
"asset"."updateId"
from
"asset" as "asset"
@@ -740,6 +748,8 @@ select
"asset"."livePhotoVideoId",
"asset"."stackId",
"asset"."libraryId",
"asset"."width",
"asset"."height",
"asset"."updateId"
from
"asset" as "asset"
@@ -789,6 +799,8 @@ select
"asset"."livePhotoVideoId",
"asset"."stackId",
"asset"."libraryId",
"asset"."width",
"asset"."height",
"asset"."updateId"
from
"asset" as "asset"

View File

@@ -232,6 +232,7 @@ export class AssetJobRepository {
'asset.livePhotoVideoId',
'asset.encodedVideoPath',
'asset.originalPath',
'asset.isOffline',
])
.$call(withExif)
.select(withFacesAndPeople)

View File

@@ -632,11 +632,9 @@ export class AssetRepository {
.coalesce(
eb
.case()
.when(sql`asset_exif."exifImageHeight" = 0 or asset_exif."exifImageWidth" = 0`)
.when(sql`asset."height" = 0 or asset."width" = 0`)
.then(eb.lit(1))
.when('asset_exif.orientation', 'in', sql<string>`('5', '6', '7', '8', '-90', '90')`)
.then(sql`round(asset_exif."exifImageHeight"::numeric / asset_exif."exifImageWidth"::numeric, 3)`)
.else(sql`round(asset_exif."exifImageWidth"::numeric / asset_exif."exifImageHeight"::numeric, 3)`)
.else(sql`round(asset."width"::numeric / asset."height"::numeric, 3)`)
.end(),
eb.lit(1),
)

View File

@@ -0,0 +1,28 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "asset" ADD COLUMN "width" integer;`.execute(db);
await sql`ALTER TABLE "asset" ADD COLUMN "height" integer;`.execute(db);
// Populate width and height from exif data with orientation-aware swapping
await sql`
UPDATE "asset"
SET
"width" = CASE
WHEN "asset_exif"."orientation" IN ('5', '6', '7', '8', '-90', '90') THEN "asset_exif"."exifImageHeight"
ELSE "asset_exif"."exifImageWidth"
END,
"height" = CASE
WHEN "asset_exif"."orientation" IN ('5', '6', '7', '8', '-90', '90') THEN "asset_exif"."exifImageWidth"
ELSE "asset_exif"."exifImageHeight"
END
FROM "asset_exif"
WHERE "asset"."id" = "asset_exif"."assetId"
AND ("asset_exif"."exifImageWidth" IS NOT NULL OR "asset_exif"."exifImageHeight" IS NOT NULL)
`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "asset" DROP COLUMN "width";`.execute(db);
await sql`ALTER TABLE "asset" DROP COLUMN "height";`.execute(db);
}

View File

@@ -137,4 +137,10 @@ export class AssetTable {
@Column({ enum: asset_visibility_enum, default: AssetVisibility.Timeline })
visibility!: Generated<AssetVisibility>;
@Column({ type: 'integer', nullable: true })
width!: number | null;
@Column({ type: 'integer', nullable: true })
height!: number | null;
}

View File

@@ -585,8 +585,6 @@ describe(AssetService.name, () => {
'/uploads/user-id/webp/path.ext',
'/uploads/user-id/thumbs/path.jpg',
'/uploads/user-id/fullsize/path.webp',
assetWithFace.encodedVideoPath, // this value is null
undefined, // no sidecar path
assetWithFace.originalPath,
],
},
@@ -648,8 +646,6 @@ describe(AssetService.name, () => {
'/uploads/user-id/webp/path.ext',
'/uploads/user-id/thumbs/path.jpg',
'/uploads/user-id/fullsize/path.webp',
undefined,
undefined,
'fake_path/asset_1.jpeg',
],
},
@@ -676,8 +672,6 @@ describe(AssetService.name, () => {
'/uploads/user-id/webp/path.ext',
'/uploads/user-id/thumbs/path.jpg',
'/uploads/user-id/fullsize/path.webp',
undefined,
undefined,
'fake_path/asset_1.jpeg',
],
},

View File

@@ -363,11 +363,11 @@ export class AssetService extends BaseService {
const { fullsizeFile, previewFile, thumbnailFile, sidecarFile } = getAssetFiles(asset.files ?? []);
const files = [thumbnailFile?.path, previewFile?.path, fullsizeFile?.path, asset.encodedVideoPath];
if (deleteOnDisk) {
if (deleteOnDisk && !asset.isOffline) {
files.push(sidecarFile?.path, asset.originalPath);
}
await this.jobRepository.queue({ name: JobName.FileDelete, data: { files } });
await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: files.filter(Boolean) } });
return JobStatus.Success;
}

View File

@@ -141,6 +141,8 @@ export class JobService extends BaseService {
livePhotoVideoId: asset.livePhotoVideoId,
stackId: asset.stackId,
libraryId: asset.libraryId,
width: asset.width,
height: asset.height,
},
exif: {
assetId: exif.assetId,

View File

@@ -221,6 +221,8 @@ describe(MetadataService.name, () => {
fileCreatedAt: fileModifiedAt,
fileModifiedAt,
localDateTime: fileModifiedAt,
width: null,
height: null,
});
});
@@ -245,6 +247,8 @@ describe(MetadataService.name, () => {
fileCreatedAt,
fileModifiedAt,
localDateTime: fileCreatedAt,
width: null,
height: null,
});
});
@@ -288,6 +292,8 @@ describe(MetadataService.name, () => {
fileCreatedAt: assetStub.image.fileCreatedAt,
fileModifiedAt: assetStub.image.fileCreatedAt,
localDateTime: assetStub.image.fileCreatedAt,
width: null,
height: null,
});
});
@@ -317,6 +323,8 @@ describe(MetadataService.name, () => {
fileCreatedAt: assetStub.withLocation.fileCreatedAt,
fileModifiedAt: assetStub.withLocation.fileModifiedAt,
localDateTime: new Date('2023-02-22T05:06:29.716Z'),
width: null,
height: null,
});
});
@@ -346,6 +354,8 @@ describe(MetadataService.name, () => {
fileCreatedAt: assetStub.withLocation.fileCreatedAt,
fileModifiedAt: assetStub.withLocation.fileModifiedAt,
localDateTime: new Date('2023-02-22T05:06:29.716Z'),
width: null,
height: null,
});
});
@@ -1517,6 +1527,49 @@ describe(MetadataService.name, () => {
}),
);
});
it('should properly set width/height for normal images', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
mockReadTags({ ImageWidth: 1000, ImageHeight: 2000 });
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(mocks.asset.update).toHaveBeenCalledWith(
expect.objectContaining({
width: 1000,
height: 2000,
}),
);
});
it('should properly swap asset width/height for rotated images', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
mockReadTags({ ImageWidth: 1000, ImageHeight: 2000, Orientation: 6 });
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(mocks.asset.update).toHaveBeenCalledWith(
expect.objectContaining({
width: 2000,
height: 1000,
}),
);
});
it('should not overwrite existing width/height if they already exist', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue({
...assetStub.image,
width: 1920,
height: 1080,
});
mockReadTags({ ImageWidth: 1280, ImageHeight: 720 });
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(mocks.asset.update).not.toHaveBeenCalledWith(
expect.objectContaining({
width: 1280,
height: 720,
}),
);
});
});
describe('handleQueueSidecar', () => {

View File

@@ -195,6 +195,15 @@ export class MetadataService extends BaseService {
await this.eventRepository.emit('AssetHide', { assetId: motionAsset.id, userId: motionAsset.ownerId });
}
private isOrientationSidewards(orientation: ExifOrientation | number): boolean {
return [
ExifOrientation.MirrorHorizontalRotate270CW,
ExifOrientation.Rotate90CW,
ExifOrientation.MirrorHorizontalRotate90CW,
ExifOrientation.Rotate270CW,
].includes(orientation);
}
@OnJob({ name: JobName.AssetExtractMetadataQueueAll, queue: QueueName.MetadataExtraction })
async handleQueueMetadataExtraction(job: JobOf<JobName.AssetExtractMetadataQueueAll>): Promise<JobStatus> {
const { force } = job;
@@ -288,6 +297,10 @@ export class MetadataService extends BaseService {
autoStackId: this.getAutoStackId(exifTags),
};
const isSidewards = exifTags.Orientation && this.isOrientationSidewards(exifTags.Orientation);
const assetWidth = isSidewards ? validate(height) : validate(width);
const assetHeight = isSidewards ? validate(width) : validate(height);
const promises: Promise<unknown>[] = [
this.assetRepository.upsertExif(exifData),
this.assetRepository.update({
@@ -296,6 +309,11 @@ export class MetadataService extends BaseService {
localDateTime: dates.localDateTime,
fileCreatedAt: dates.dateTimeOriginal ?? undefined,
fileModifiedAt: stats.mtime,
// only update the dimensions if they don't already exist
// we don't want to overwrite width/height that are modified by edits
width: asset.width == null ? assetWidth : undefined,
height: asset.height == null ? assetHeight : undefined,
}),
this.applyTagList(asset, exifTags),
];
@@ -698,12 +716,7 @@ export class MetadataService extends BaseService {
return regionInfo;
}
const isSidewards = [
ExifOrientation.MirrorHorizontalRotate270CW,
ExifOrientation.Rotate90CW,
ExifOrientation.MirrorHorizontalRotate90CW,
ExifOrientation.Rotate270CW,
].includes(orientation);
const isSidewards = this.isOrientationSidewards(orientation);
// swap image dimensions in AppliedToDimensions if orientation is sidewards
const adjustedAppliedToDimensions = isSidewards
@@ -949,9 +962,17 @@ export class MetadataService extends BaseService {
private async getVideoTags(originalPath: string) {
const { videoStreams, format } = await this.mediaRepository.probe(originalPath);
const tags: Pick<ImmichTags, 'Duration' | 'Orientation'> = {};
const tags: Pick<ImmichTags, 'Duration' | 'Orientation' | 'ImageWidth' | 'ImageHeight'> = {};
if (videoStreams[0]) {
// Set video dimensions
if (videoStreams[0].width) {
tags.ImageWidth = videoStreams[0].width;
}
if (videoStreams[0].height) {
tags.ImageHeight = videoStreams[0].height;
}
switch (videoStreams[0].rotation) {
case -90: {
tags.Orientation = ExifOrientation.Rotate90CW;

View File

@@ -101,6 +101,8 @@ export const assetStub = {
stackId: null,
updateId: '42',
visibility: AssetVisibility.Timeline,
width: null,
height: null,
}),
noWebpPath: Object.freeze({
@@ -139,6 +141,8 @@ export const assetStub = {
stackId: null,
updateId: '42',
visibility: AssetVisibility.Timeline,
width: null,
height: null,
}),
noThumbhash: Object.freeze({
@@ -174,6 +178,8 @@ export const assetStub = {
stackId: null,
updateId: '42',
visibility: AssetVisibility.Timeline,
width: null,
height: null,
}),
primaryImage: Object.freeze({
@@ -219,6 +225,8 @@ export const assetStub = {
updateId: '42',
libraryId: null,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
}),
image: Object.freeze({
@@ -261,8 +269,8 @@ export const assetStub = {
stack: null,
orientation: '',
projectionType: null,
height: 3840,
width: 2160,
height: null,
width: null,
visibility: AssetVisibility.Timeline,
}),
@@ -304,6 +312,8 @@ export const assetStub = {
stackId: null,
updateId: '42',
visibility: AssetVisibility.Timeline,
width: null,
height: null,
}),
trashedOffline: Object.freeze({
@@ -344,6 +354,8 @@ export const assetStub = {
stackId: null,
updateId: '42',
visibility: AssetVisibility.Timeline,
width: null,
height: null,
}),
archived: Object.freeze({
id: 'asset-id',
@@ -383,6 +395,8 @@ export const assetStub = {
stackId: null,
updateId: '42',
visibility: AssetVisibility.Timeline,
width: null,
height: null,
}),
external: Object.freeze({
@@ -422,6 +436,8 @@ export const assetStub = {
stackId: null,
stack: null,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
}),
image1: Object.freeze({
@@ -461,6 +477,8 @@ export const assetStub = {
libraryId: null,
stack: null,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
}),
imageFrom2015: Object.freeze({
@@ -499,6 +517,8 @@ export const assetStub = {
duplicateId: null,
isOffline: false,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
}),
video: Object.freeze({
@@ -539,6 +559,8 @@ export const assetStub = {
libraryId: null,
stackId: null,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
}),
livePhotoMotionAsset: Object.freeze({
@@ -556,6 +578,8 @@ export const assetStub = {
files: [] as AssetFile[],
libraryId: null,
visibility: AssetVisibility.Hidden,
width: null,
height: null,
} as MapAsset & { faces: AssetFace[]; files: AssetFile[]; exifInfo: Exif }),
livePhotoStillAsset: Object.freeze({
@@ -574,6 +598,8 @@ export const assetStub = {
files,
faces: [] as AssetFace[],
visibility: AssetVisibility.Timeline,
width: null,
height: null,
} as MapAsset & { faces: AssetFace[]; files: AssetFile[] }),
livePhotoWithOriginalFileName: Object.freeze({
@@ -594,6 +620,8 @@ export const assetStub = {
libraryId: null,
faces: [] as AssetFace[],
visibility: AssetVisibility.Timeline,
width: null,
height: null,
} as MapAsset & { faces: AssetFace[]; files: AssetFile[] }),
withLocation: Object.freeze({
@@ -638,6 +666,8 @@ export const assetStub = {
isOffline: false,
tags: [],
visibility: AssetVisibility.Timeline,
width: null,
height: null,
}),
sidecar: Object.freeze({
@@ -673,6 +703,8 @@ export const assetStub = {
libraryId: null,
stackId: null,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
}),
sidecarWithoutExt: Object.freeze({
@@ -705,6 +737,8 @@ export const assetStub = {
duplicateId: null,
isOffline: false,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
}),
hasEncodedVideo: Object.freeze({
@@ -744,6 +778,8 @@ export const assetStub = {
stackId: null,
stack: null,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
}),
hasFileExtension: Object.freeze({
@@ -780,6 +816,8 @@ export const assetStub = {
duplicateId: null,
isOffline: false,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
}),
imageDng: Object.freeze({
@@ -820,6 +858,8 @@ export const assetStub = {
libraryId: null,
stackId: null,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
}),
imageHif: Object.freeze({
@@ -860,6 +900,8 @@ export const assetStub = {
libraryId: null,
stackId: null,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
}),
panoramaTif: Object.freeze({
id: 'asset-id',
@@ -899,5 +941,7 @@ export const assetStub = {
libraryId: null,
stackId: null,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
}),
};

View File

@@ -72,6 +72,8 @@ const assetResponse: AssetResponseDto = {
libraryId: 'library-id',
hasMetadata: true,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
};
const assetResponseWithoutMetadata = {
@@ -83,6 +85,8 @@ const assetResponseWithoutMetadata = {
duration: '0:00:00.00000',
livePhotoVideoId: null,
hasMetadata: false,
width: 500,
height: 500,
} as AssetResponseDto;
const albumResponse: AlbumResponseDto = {
@@ -257,6 +261,8 @@ export const sharedLinkStub = {
libraryId: null,
stackId: null,
visibility: AssetVisibility.Timeline,
width: 500,
height: 500,
},
],
},

View File

@@ -2,13 +2,16 @@ import { Kysely } from 'kysely';
import { AssetFileType, JobName, SharedLinkType } from 'src/enum';
import { AccessRepository } from 'src/repositories/access.repository';
import { AlbumRepository } from 'src/repositories/album.repository';
import { AssetJobRepository } from 'src/repositories/asset-job.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
import { EventRepository } from 'src/repositories/event.repository';
import { JobRepository } from 'src/repositories/job.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { SharedLinkAssetRepository } from 'src/repositories/shared-link-asset.repository';
import { SharedLinkRepository } from 'src/repositories/shared-link.repository';
import { StackRepository } from 'src/repositories/stack.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { DB } from 'src/schema';
import { AssetService } from 'src/services/asset.service';
import { newMediumService } from 'test/medium.factory';
@@ -20,8 +23,16 @@ let defaultDatabase: Kysely<DB>;
const setup = (db?: Kysely<DB>) => {
return newMediumService(AssetService, {
database: db || defaultDatabase,
real: [AssetRepository, AlbumRepository, AccessRepository, SharedLinkAssetRepository, StackRepository],
mock: [LoggingRepository, JobRepository, StorageRepository],
real: [
AssetRepository,
AssetJobRepository,
AlbumRepository,
AccessRepository,
SharedLinkAssetRepository,
StackRepository,
UserRepository,
],
mock: [EventRepository, LoggingRepository, JobRepository, StorageRepository],
});
};
@@ -210,4 +221,51 @@ describe(AssetService.name, () => {
});
});
});
describe('delete', () => {
it('should delete asset', async () => {
const { sut, ctx } = setup();
ctx.getMock(EventRepository).emit.mockResolvedValue();
ctx.getMock(JobRepository).queue.mockResolvedValue();
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
const thumbnailPath = '/path/to/thumbnail.jpg';
const previewPath = '/path/to/preview.jpg';
const sidecarPath = '/path/to/sidecar.xmp';
await Promise.all([
ctx.newAssetFile({ assetId: asset.id, type: AssetFileType.Thumbnail, path: thumbnailPath }),
ctx.newAssetFile({ assetId: asset.id, type: AssetFileType.Preview, path: previewPath }),
ctx.newAssetFile({ assetId: asset.id, type: AssetFileType.Sidecar, path: sidecarPath }),
]);
await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true });
expect(ctx.getMock(JobRepository).queue).toHaveBeenCalledWith({
name: JobName.FileDelete,
data: { files: [thumbnailPath, previewPath, sidecarPath, asset.originalPath] },
});
});
it('should not delete offline assets', async () => {
const { sut, ctx } = setup();
ctx.getMock(EventRepository).emit.mockResolvedValue();
ctx.getMock(JobRepository).queue.mockResolvedValue();
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id, isOffline: true });
const thumbnailPath = '/path/to/thumbnail.jpg';
const previewPath = '/path/to/preview.jpg';
await Promise.all([
ctx.newAssetFile({ assetId: asset.id, type: AssetFileType.Thumbnail, path: thumbnailPath }),
ctx.newAssetFile({ assetId: asset.id, type: AssetFileType.Preview, path: previewPath }),
ctx.newAssetFile({ assetId: asset.id, type: AssetFileType.Sidecar, path: `/path/to/sidecar.xmp` }),
]);
await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true });
expect(ctx.getMock(JobRepository).queue).toHaveBeenCalledWith({
name: JobName.FileDelete,
data: { files: [thumbnailPath, previewPath] },
});
});
});
});

View File

@@ -52,6 +52,8 @@ describe(SyncRequestType.AlbumAssetsV1, () => {
livePhotoVideoId: null,
stackId: null,
libraryId: null,
width: 1920,
height: 1080,
});
const { album } = await ctx.newAlbum({ ownerId: user2.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
@@ -79,6 +81,8 @@ describe(SyncRequestType.AlbumAssetsV1, () => {
livePhotoVideoId: asset.livePhotoVideoId,
stackId: asset.stackId,
libraryId: asset.libraryId,
width: asset.width,
height: asset.height,
},
type: SyncEntityType.AlbumAssetCreateV1,
},

View File

@@ -37,6 +37,8 @@ describe(SyncEntityType.AssetV1, () => {
deletedAt: null,
duration: '0:10:00.00000',
libraryId: null,
width: 1920,
height: 1080,
});
const response = await ctx.syncStream(auth, [SyncRequestType.AssetsV1]);
@@ -60,6 +62,8 @@ describe(SyncEntityType.AssetV1, () => {
stackId: null,
livePhotoVideoId: null,
libraryId: asset.libraryId,
width: asset.width,
height: asset.height,
},
type: 'AssetV1',
},

View File

@@ -66,6 +66,8 @@ describe(SyncRequestType.PartnerAssetsV1, () => {
stackId: null,
livePhotoVideoId: null,
libraryId: asset.libraryId,
width: null,
height: null,
},
type: SyncEntityType.PartnerAssetV1,
},

View File

@@ -249,6 +249,8 @@ const assetFactory = (asset: Partial<MapAsset> = {}) => ({
thumbhash: null,
type: AssetType.Image,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
...asset,
});

View File

@@ -61,7 +61,8 @@
"svelte-maplibre": "^1.2.5",
"svelte-persisted-store": "^0.12.0",
"tabbable": "^6.2.0",
"thumbhash": "^0.1.1"
"thumbhash": "^0.1.1",
"uplot": "^1.6.32"
},
"devDependencies": {
"@eslint/js": "^9.36.0",

View File

@@ -1,11 +1,15 @@
<script lang="ts">
import QueueCardBadge from '$lib/components/QueueCardBadge.svelte';
import QueueCardButton from '$lib/components/QueueCardButton.svelte';
import Badge from '$lib/elements/Badge.svelte';
import { asQueueItem, getQueueDetailUrl } from '$lib/services/queue.service';
import { locale } from '$lib/stores/preferences.store';
import { QueueCommand, type QueueCommandDto, type QueueStatisticsDto, type QueueStatusLegacyDto } from '@immich/sdk';
import { Icon, IconButton } from '@immich/ui';
import { QueueCommand, type QueueCommandDto, type QueueResponseDto } from '@immich/sdk';
import { Icon, IconButton, Link } from '@immich/ui';
import {
mdiAlertCircle,
mdiAllInclusive,
mdiChartLine,
mdiClose,
mdiFastForward,
mdiImageRefreshOutline,
@@ -15,39 +19,23 @@
} from '@mdi/js';
import { type Component } from 'svelte';
import { t } from 'svelte-i18n';
import JobTileButton from './JobTileButton.svelte';
import JobTileStatus from './JobTileStatus.svelte';
interface Props {
title: string;
subtitle: string | undefined;
description: Component | undefined;
statistics: QueueStatisticsDto;
queueStatus: QueueStatusLegacyDto;
icon: string;
queue: QueueResponseDto;
description?: Component;
disabled?: boolean;
allText: string | undefined;
refreshText: string | undefined;
allText?: string;
refreshText?: string;
missingText: string;
onCommand: (command: QueueCommandDto) => void;
}
let {
title,
subtitle,
description,
statistics,
queueStatus,
icon,
disabled = false,
allText,
refreshText,
missingText,
onCommand,
}: Props = $props();
let { queue, description, disabled = false, allText, refreshText, missingText, onCommand }: Props = $props();
const { icon, title, subtitle } = $derived(asQueueItem($t, queue));
const { statistics } = $derived(queue);
let waitingCount = $derived(statistics.waiting + statistics.paused + statistics.delayed);
let isIdle = $derived(!queueStatus.isActive && !queueStatus.isPaused);
let isIdle = $derived(statistics.active + statistics.waiting === 0 && !queue.isPaused);
let multipleButtons = $derived(allText || refreshText);
const commonClasses = 'flex place-items-center justify-between w-full py-2 sm:py-4 pe-4 ps-6';
@@ -55,17 +43,25 @@
<div class="flex flex-col overflow-hidden rounded-2xl bg-gray-100 dark:bg-immich-dark-gray sm:flex-row sm:rounded-9">
<div class="flex w-full flex-col">
{#if queueStatus.isPaused}
<JobTileStatus color="warning">{$t('paused')}</JobTileStatus>
{:else if queueStatus.isActive}
<JobTileStatus color="success">{$t('active')}</JobTileStatus>
{#if queue.isPaused}
<QueueCardBadge color="warning">{$t('paused')}</QueueCardBadge>
{:else if statistics.active > 0}
<QueueCardBadge color="success">{$t('active')}</QueueCardBadge>
{/if}
<div class="flex flex-col gap-2 p-5 sm:p-7 md:p-9">
<div class="flex items-center gap-4 text-xl font-semibold text-primary">
<span class="flex items-center gap-2">
<div class="flex items-center gap-2 text-xl font-semibold text-primary">
<Link class="flex items-center gap-2 hover:underline" href={getQueueDetailUrl(queue)} underline={false}>
<Icon {icon} size="1.25em" class="hidden shrink-0 sm:block" />
<span class="uppercase">{title}</span>
</span>
</Link>
<IconButton
color="primary"
icon={mdiChartLine}
aria-label={$t('view_details')}
size="small"
variant="ghost"
href={getQueueDetailUrl(queue)}
/>
<div class="flex gap-2">
{#if statistics.failed > 0}
<Badge>
@@ -128,62 +124,62 @@
</div>
<div class="flex w-full flex-row overflow-hidden sm:w-32 sm:flex-col">
{#if disabled}
<JobTileButton
<QueueCardButton
disabled={true}
color="light-gray"
onClick={() => onCommand({ command: QueueCommand.Start, force: false })}
>
<Icon icon={mdiAlertCircle} size="36" />
<span class="uppercase">{$t('disabled')}</span>
</JobTileButton>
</QueueCardButton>
{/if}
{#if !disabled && !isIdle}
{#if waitingCount > 0}
<JobTileButton color="gray" onClick={() => onCommand({ command: QueueCommand.Empty, force: false })}>
<QueueCardButton color="gray" onClick={() => onCommand({ command: QueueCommand.Empty, force: false })}>
<Icon icon={mdiClose} size="24" />
<span class="uppercase">{$t('clear')}</span>
</JobTileButton>
</QueueCardButton>
{/if}
{#if queueStatus.isPaused}
{#if queue.isPaused}
{@const size = waitingCount > 0 ? '24' : '48'}
<JobTileButton color="light-gray" onClick={() => onCommand({ command: QueueCommand.Resume, force: false })}>
<QueueCardButton color="light-gray" onClick={() => onCommand({ command: QueueCommand.Resume, force: false })}>
<!-- size property is not reactive, so have to use width and height -->
<Icon icon={mdiFastForward} {size} />
<span class="uppercase">{$t('resume')}</span>
</JobTileButton>
</QueueCardButton>
{:else}
<JobTileButton color="light-gray" onClick={() => onCommand({ command: QueueCommand.Pause, force: false })}>
<QueueCardButton color="light-gray" onClick={() => onCommand({ command: QueueCommand.Pause, force: false })}>
<Icon icon={mdiPause} size="24" />
<span class="uppercase">{$t('pause')}</span>
</JobTileButton>
</QueueCardButton>
{/if}
{/if}
{#if !disabled && multipleButtons && isIdle}
{#if allText}
<JobTileButton color="dark-gray" onClick={() => onCommand({ command: QueueCommand.Start, force: true })}>
<QueueCardButton color="dark-gray" onClick={() => onCommand({ command: QueueCommand.Start, force: true })}>
<Icon icon={mdiAllInclusive} size="24" />
<span class="uppercase">{allText}</span>
</JobTileButton>
</QueueCardButton>
{/if}
{#if refreshText}
<JobTileButton color="gray" onClick={() => onCommand({ command: QueueCommand.Start, force: undefined })}>
<QueueCardButton color="gray" onClick={() => onCommand({ command: QueueCommand.Start, force: undefined })}>
<Icon icon={mdiImageRefreshOutline} size="24" />
<span class="uppercase">{refreshText}</span>
</JobTileButton>
</QueueCardButton>
{/if}
<JobTileButton color="light-gray" onClick={() => onCommand({ command: QueueCommand.Start, force: false })}>
<QueueCardButton color="light-gray" onClick={() => onCommand({ command: QueueCommand.Start, force: false })}>
<Icon icon={mdiSelectionSearch} size="24" />
<span class="uppercase">{missingText}</span>
</JobTileButton>
</QueueCardButton>
{/if}
{#if !disabled && !multipleButtons && isIdle}
<JobTileButton color="light-gray" onClick={() => onCommand({ command: QueueCommand.Start, force: false })}>
<QueueCardButton color="light-gray" onClick={() => onCommand({ command: QueueCommand.Start, force: false })}>
<Icon icon={mdiPlay} size="48" />
<span class="uppercase">{missingText}</span>
</JobTileButton>
</QueueCardButton>
{/if}
</div>
</div>

View File

@@ -0,0 +1,160 @@
<script lang="ts">
import { queueManager } from '$lib/managers/queue-manager.svelte';
import type { QueueSnapshot } from '$lib/types';
import type { QueueResponseDto } from '@immich/sdk';
import { LoadingSpinner, Theme, theme } from '@immich/ui';
import { DateTime } from 'luxon';
import { onMount } from 'svelte';
import uPlot, { type AlignedData, type Axis } from 'uplot';
import 'uplot/dist/uPlot.min.css';
type Props = {
queue: QueueResponseDto;
class?: string;
};
const { queue, class: className = '' }: Props = $props();
type Data = number | null;
type NormalizedData = [
Data[], // timestamps
Data[], // failed counts
Data[], // active counts
Data[], // waiting counts
];
const normalizeData = (snapshots: QueueSnapshot[]) => {
const items: NormalizedData = [[], [], [], []];
for (const { timestamp, snapshot } of snapshots) {
items[0].push(timestamp);
const statistics = (snapshot || []).find(({ name }) => name === queue.name)?.statistics;
if (statistics) {
items[1].push(statistics.failed);
items[2].push(statistics.active);
items[3].push(statistics.waiting + statistics.paused);
} else {
items[0].push(timestamp);
items[1].push(null);
items[2].push(null);
items[3].push(null);
}
}
items[0].push(Date.now() + 5000);
items[1].push(items[1].at(-1) ?? 0);
items[2].push(items[2].at(-1) ?? 0);
items[3].push(items[3].at(-1) ?? 0);
return items;
};
const data = $derived(normalizeData(queueManager.snapshots));
let chartElement: HTMLDivElement | undefined = $state();
let isDark = $derived(theme.value === Theme.Dark);
let plot: uPlot;
const axisOptions: Axis = {
stroke: () => (isDark ? '#ccc' : 'black'),
ticks: {
show: true,
stroke: () => (isDark ? '#444' : '#ddd'),
},
grid: {
show: true,
stroke: () => (isDark ? '#444' : '#ddd'),
},
};
const seriesOptions: uPlot.Series = {
spanGaps: false,
points: {
show: false,
},
width: 2,
};
const options: uPlot.Options = {
legend: {
show: false,
},
cursor: {
show: false,
lock: true,
drag: {
setScale: false,
},
},
width: 200,
height: 200,
ms: 1,
pxAlign: true,
scales: {
y: {
distr: 1,
},
},
series: [
{},
{
stroke: '#d94a4a',
...seriesOptions,
},
{
stroke: '#4250af',
...seriesOptions,
},
{
stroke: '#1075db',
...seriesOptions,
},
],
axes: [
{
...axisOptions,
values: (plot, values) => {
return values.map((value) => {
if (!value) {
return '';
}
return DateTime.fromMillis(value).toFormat('hh:mm:ss');
});
},
},
axisOptions,
],
};
const onThemeChange = () => plot?.redraw(false);
$effect(() => theme.value && onThemeChange());
onMount(() => {
plot = new uPlot(options, data as AlignedData, chartElement);
});
const update = () => {
if (plot && chartElement && data[0].length > 0) {
const now = Date.now();
const scale = { min: now - chartElement!.clientWidth * 100, max: now };
plot.setData(data as AlignedData, false);
plot.setScale('x', scale);
plot.setSize({ width: chartElement.clientWidth, height: chartElement.clientHeight });
}
requestAnimationFrame(update);
};
requestAnimationFrame(update);
</script>
<div class="w-full {className}" bind:this={chartElement}>
{#if data[0].length === 0}
<LoadingSpinner size="giant" />
{/if}
</div>

View File

@@ -0,0 +1,132 @@
<script lang="ts">
import QueueCard from '$lib/components/QueueCard.svelte';
import QueueStorageMigrationDescription from '$lib/components/QueueStorageMigrationDescription.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { queueManager } from '$lib/managers/queue-manager.svelte';
import { asQueueItem } from '$lib/services/queue.service';
import { handleError } from '$lib/utils/handle-error';
import {
QueueCommand,
type QueueCommandDto,
QueueName,
type QueueResponseDto,
runQueueCommandLegacy,
} from '@immich/sdk';
import { modalManager, toastManager } from '@immich/ui';
import type { Component } from 'svelte';
import { t } from 'svelte-i18n';
type Props = {
queues: QueueResponseDto[];
};
let { queues }: Props = $props();
const featureFlags = featureFlagsManager.value;
type QueueDetails = {
description?: Component;
allText?: string;
refreshText?: string;
missingText: string;
disabled?: boolean;
handleCommand?: (jobId: QueueName, jobCommand: QueueCommandDto) => Promise<void>;
};
const queueDetails: Partial<Record<QueueName, QueueDetails>> = {
[QueueName.ThumbnailGeneration]: {
allText: $t('all'),
missingText: $t('missing'),
},
[QueueName.MetadataExtraction]: {
allText: $t('all'),
missingText: $t('missing'),
},
[QueueName.Library]: {
missingText: $t('rescan'),
},
[QueueName.Sidecar]: {
allText: $t('sync'),
missingText: $t('discover'),
disabled: !featureFlags.sidecar,
},
[QueueName.SmartSearch]: {
allText: $t('all'),
missingText: $t('missing'),
disabled: !featureFlags.smartSearch,
},
[QueueName.DuplicateDetection]: {
allText: $t('all'),
missingText: $t('missing'),
disabled: !featureFlags.duplicateDetection,
},
[QueueName.FaceDetection]: {
allText: $t('reset'),
refreshText: $t('refresh'),
missingText: $t('missing'),
disabled: !featureFlags.facialRecognition,
},
[QueueName.FacialRecognition]: {
allText: $t('reset'),
missingText: $t('missing'),
disabled: !featureFlags.facialRecognition,
},
[QueueName.Ocr]: {
allText: $t('all'),
missingText: $t('missing'),
disabled: !featureFlags.ocr,
},
[QueueName.VideoConversion]: {
allText: $t('all'),
missingText: $t('missing'),
},
[QueueName.StorageTemplateMigration]: {
missingText: $t('start'),
description: QueueStorageMigrationDescription,
},
[QueueName.Migration]: {
missingText: $t('start'),
},
};
let queueList = Object.entries(queueDetails) as [QueueName, QueueDetails][];
const handleCommand = async (name: QueueName, dto: QueueCommandDto) => {
const item = asQueueItem($t, { name });
switch (name) {
case QueueName.FaceDetection:
case QueueName.FacialRecognition: {
if (dto.force) {
const confirmed = await modalManager.showDialog({ prompt: $t('admin.confirm_reprocess_all_faces') });
if (!confirmed) {
return;
}
break;
}
}
}
try {
await runQueueCommandLegacy({ name, queueCommandDto: dto });
await queueManager.refresh();
switch (dto.command) {
case QueueCommand.Empty: {
toastManager.success($t('admin.cleared_jobs', { values: { job: item.title } }));
break;
}
}
} catch (error) {
handleError(error, $t('admin.failed_job_command', { values: { command: dto.command, job: item.title } }));
}
};
</script>
<div class="flex flex-col gap-7 mt-10">
{#each queueList as [queueName, props] (queueName)}
{@const queue = queues.find(({ name }) => name === queueName)}
{#if queue}
<QueueCard {queue} onCommand={(command) => handleCommand(queueName, command)} {...props} />
{/if}
{/each}
</div>

View File

@@ -254,7 +254,7 @@
values={{ job: $t('admin.storage_template_migration_job') }}
>
{#snippet children({ message })}
<a href={resolve(AppRoute.ADMIN_JOBS)} class="text-primary">
<a href={resolve(AppRoute.ADMIN_QUEUES)} class="text-primary">
{message}
</a>
{/snippet}

View File

@@ -98,7 +98,7 @@
<ControlAppBar showBackButton={false}>
{#snippet leading()}
<a data-sveltekit-preload-data="hover" class="ms-4" href="/">
<Logo variant="inline" />
<Logo variant="inline" class="min-w-min" />
</a>
{/snippet}

View File

@@ -1,197 +0,0 @@
<script lang="ts">
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { getQueueName } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import {
QueueCommand,
type QueueCommandDto,
QueueName,
type QueuesResponseLegacyDto,
runQueueCommandLegacy,
} from '@immich/sdk';
import { modalManager, toastManager } from '@immich/ui';
import {
mdiContentDuplicate,
mdiFaceRecognition,
mdiFileJpgBox,
mdiFileXmlBox,
mdiFolderMove,
mdiImageSearch,
mdiLibraryShelves,
mdiOcr,
mdiTable,
mdiTagFaces,
mdiVideo,
} from '@mdi/js';
import type { Component } from 'svelte';
import { t } from 'svelte-i18n';
import JobTile from './JobTile.svelte';
import StorageMigrationDescription from './StorageMigrationDescription.svelte';
interface Props {
jobs: QueuesResponseLegacyDto;
}
let { jobs = $bindable() }: Props = $props();
const featureFlags = featureFlagsManager.value;
type JobDetails = {
title: string;
subtitle?: string;
description?: Component;
allText?: string;
refreshText?: string;
missingText: string;
disabled?: boolean;
icon: string;
handleCommand?: (jobId: QueueName, jobCommand: QueueCommandDto) => Promise<void>;
};
const handleConfirmCommand = async (jobId: QueueName, dto: QueueCommandDto) => {
if (dto.force) {
const isConfirmed = await modalManager.showDialog({
prompt: $t('admin.confirm_reprocess_all_faces'),
});
if (isConfirmed) {
await handleCommand(jobId, { command: QueueCommand.Start, force: true });
return;
}
return;
}
await handleCommand(jobId, dto);
};
let jobDetails: Partial<Record<QueueName, JobDetails>> = {
[QueueName.ThumbnailGeneration]: {
icon: mdiFileJpgBox,
title: $getQueueName(QueueName.ThumbnailGeneration),
subtitle: $t('admin.thumbnail_generation_job_description'),
allText: $t('all'),
missingText: $t('missing'),
},
[QueueName.MetadataExtraction]: {
icon: mdiTable,
title: $getQueueName(QueueName.MetadataExtraction),
subtitle: $t('admin.metadata_extraction_job_description'),
allText: $t('all'),
missingText: $t('missing'),
},
[QueueName.Library]: {
icon: mdiLibraryShelves,
title: $getQueueName(QueueName.Library),
subtitle: $t('admin.library_tasks_description'),
missingText: $t('rescan'),
},
[QueueName.Sidecar]: {
title: $getQueueName(QueueName.Sidecar),
icon: mdiFileXmlBox,
subtitle: $t('admin.sidecar_job_description'),
allText: $t('sync'),
missingText: $t('discover'),
disabled: !featureFlags.sidecar,
},
[QueueName.SmartSearch]: {
icon: mdiImageSearch,
title: $getQueueName(QueueName.SmartSearch),
subtitle: $t('admin.smart_search_job_description'),
allText: $t('all'),
missingText: $t('missing'),
disabled: !featureFlags.smartSearch,
},
[QueueName.DuplicateDetection]: {
icon: mdiContentDuplicate,
title: $getQueueName(QueueName.DuplicateDetection),
subtitle: $t('admin.duplicate_detection_job_description'),
allText: $t('all'),
missingText: $t('missing'),
disabled: !featureFlags.duplicateDetection,
},
[QueueName.FaceDetection]: {
icon: mdiFaceRecognition,
title: $getQueueName(QueueName.FaceDetection),
subtitle: $t('admin.face_detection_description'),
allText: $t('reset'),
refreshText: $t('refresh'),
missingText: $t('missing'),
handleCommand: handleConfirmCommand,
disabled: !featureFlags.facialRecognition,
},
[QueueName.FacialRecognition]: {
icon: mdiTagFaces,
title: $getQueueName(QueueName.FacialRecognition),
subtitle: $t('admin.facial_recognition_job_description'),
allText: $t('reset'),
missingText: $t('missing'),
handleCommand: handleConfirmCommand,
disabled: !featureFlags.facialRecognition,
},
[QueueName.Ocr]: {
icon: mdiOcr,
title: $getQueueName(QueueName.Ocr),
subtitle: $t('admin.ocr_job_description'),
allText: $t('all'),
missingText: $t('missing'),
disabled: !featureFlags.ocr,
},
[QueueName.VideoConversion]: {
icon: mdiVideo,
title: $getQueueName(QueueName.VideoConversion),
subtitle: $t('admin.video_conversion_job_description'),
allText: $t('all'),
missingText: $t('missing'),
},
[QueueName.StorageTemplateMigration]: {
icon: mdiFolderMove,
title: $getQueueName(QueueName.StorageTemplateMigration),
missingText: $t('start'),
description: StorageMigrationDescription,
},
[QueueName.Migration]: {
icon: mdiFolderMove,
title: $getQueueName(QueueName.Migration),
subtitle: $t('admin.migration_job_description'),
missingText: $t('start'),
},
};
let jobList = Object.entries(jobDetails) as [QueueName, JobDetails][];
async function handleCommand(name: QueueName, dto: QueueCommandDto) {
const title = jobDetails[name]?.title;
try {
jobs[name] = await runQueueCommandLegacy({ name, queueCommandDto: dto });
switch (dto.command) {
case QueueCommand.Empty: {
toastManager.success($t('admin.cleared_jobs', { values: { job: title } }));
break;
}
}
} catch (error) {
handleError(error, $t('admin.failed_job_command', { values: { command: dto.command, job: title } }));
}
}
</script>
<div class="flex flex-col gap-7">
{#each jobList as [jobName, { title, subtitle, description, disabled, allText, refreshText, missingText, icon, handleCommand: handleCommandOverride }] (jobName)}
{@const { jobCounts: statistics, queueStatus } = jobs[jobName]}
<JobTile
{icon}
{title}
{disabled}
{subtitle}
{description}
{allText}
{refreshText}
{missingText}
{statistics}
{queueStatus}
onCommand={(command) => (handleCommandOverride || handleCommand)(jobName, command)}
/>
{/each}
</div>

View File

@@ -1,3 +1,7 @@
<script lang="ts" module>
export const headerId = 'user-page-header';
</script>
<script lang="ts">
import { useActions, type ActionArray } from '$lib/actions/use-actions';
import NavigationBar from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte';
@@ -64,7 +68,7 @@
<div class="absolute flex h-16 w-full place-items-center justify-between border-b p-2 text-dark">
<div class="flex gap-2 items-center">
{#if title}
<div class="font-medium outline-none pe-8" tabindex="-1">{title}</div>
<div class="font-medium outline-none pe-8" tabindex="-1" id={headerId}>{title}</div>
{/if}
{#if description}
<p class="text-sm text-gray-400 dark:text-gray-600">{description}</p>

View File

@@ -7,14 +7,15 @@
active: string;
icons: { default: string; active: string };
getLink: (path: string) => string;
isNested?: boolean;
}
let { tree, active, icons, getLink, isNested = false }: Props = $props();
let { tree, active, icons, getLink }: Props = $props();
</script>
<ul role={isNested ? 'group' : 'tree'} class="list-none ms-2">
<ul class="list-none ms-2">
{#each tree.children as node (node.color ? node.path + node.color : node.path)}
<Tree {node} {icons} {active} {getLink} />
<li>
<Tree {node} {icons} {active} {getLink} />
</li>
{/each}
</ul>

View File

@@ -1,5 +1,4 @@
<script lang="ts">
import { goto } from '$app/navigation';
import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte';
import { TreeNode } from '$lib/utils/tree-utils';
import { Icon } from '@immich/ui';
@@ -22,108 +21,30 @@
event.preventDefault();
isOpen = !isOpen;
};
const handleSelect = (event: MouseEvent | KeyboardEvent, path: string) => {
event.preventDefault();
event.stopPropagation();
navigateTo(path);
};
const handleKeydown = (event: KeyboardEvent, node: TreeNode) => {
switch (event.key) {
case 'Enter':
case ' ': {
handleSelect(event, node.path);
break;
}
case 'ArrowRight': {
event.preventDefault();
event.stopPropagation();
const hasChildren = node.children.length > 0;
if (isOpen && hasChildren) {
const target = event.target as HTMLElement;
const child = target.querySelector<HTMLLIElement>('ul[role="group"] > li[role="treeitem"]');
child?.focus();
} else if (!isOpen && hasChildren) {
isOpen = true;
}
break;
}
case 'ArrowLeft': {
event.preventDefault();
event.stopPropagation();
const hasChildren = node.children.length > 0;
if (isOpen && hasChildren) {
isOpen = false;
} else if (node.parents.length > 0) {
const target = event.target as HTMLElement;
const parent = target.parentElement?.closest<HTMLLIElement>('li[role="treeitem"]');
parent?.focus();
}
break;
}
case 'ArrowUp': {
event.preventDefault();
event.stopPropagation();
console.log('focus previous node');
break;
}
case 'ArrowDown': {
event.preventDefault();
event.stopPropagation();
console.log('focus next node');
break;
}
}
};
const navigateTo = (path: string) => {
const link = getLink(path);
void goto(link, { keepFocus: true });
};
</script>
<!-- href={getLink(node.path)} -->
<li
role="treeitem"
aria-selected={false}
tabindex="0"
class="outline-none"
onkeydown={(event) => handleKeydown(event, node)}
onclick={(event) => handleSelect(event, node.path)}
<a
href={getLink(node.path)}
title={node.value}
class={`flex grow place-items-center ps-2 py-1 text-sm rounded-lg hover:bg-slate-200 dark:hover:bg-slate-800 hover:font-semibold ${isTarget ? 'bg-slate-100 dark:bg-slate-700 font-semibold text-primary' : 'dark:text-gray-200'}`}
data-sveltekit-keepfocus
>
<div
class={`flex grow place-items-center ps-2 py-1 text-sm rounded-lg cursor-pointer hover:bg-slate-200 dark:hover:bg-slate-800 hover:font-semibold ${isTarget ? 'bg-slate-100 dark:bg-slate-700 font-semibold text-primary' : 'dark:text-gray-200'}`}
>
{#if node.size > 0}
<button tabindex={-1} aria-hidden="true" type="button" {onclick}>
<Icon icon={isOpen ? mdiChevronDown : mdiChevronRight} class="text-gray-400" size="20" />
</button>
{/if}
<div class={node.size === 0 ? 'ml-[1.5em] ' : ''}>
<Icon
icon={isActive ? icons.active : icons.default}
class={isActive ? 'text-primary' : 'text-gray-400'}
color={node.color}
size="20"
/>
</div>
<span class="text-nowrap overflow-hidden text-ellipsis font-mono ps-1 pt-1 whitespace-pre-wrap">{node.value}</span>
</div>
{#if isOpen}
<TreeItems tree={node} {icons} {active} {getLink} isNested />
{#if node.size > 0}
<button type="button" {onclick}>
<Icon icon={isOpen ? mdiChevronDown : mdiChevronRight} class="text-gray-400" size="20" />
</button>
{/if}
</li>
<div class={node.size === 0 ? 'ml-[1.5em] ' : ''}>
<Icon
icon={isActive ? icons.active : icons.default}
class={isActive ? 'text-primary' : 'text-gray-400'}
color={node.color}
size="20"
/>
</div>
<span class="text-nowrap overflow-hidden text-ellipsis font-mono ps-1 pt-1 whitespace-pre-wrap">{node.value}</span>
</a>
<style>
li[role='treeitem']:focus-visible > div {
outline-style: var(--tw-outline-style);
outline-width: 2px;
}
</style>
{#if isOpen}
<TreeItems tree={node} {icons} {active} {getLink} />
{/if}

View File

@@ -23,7 +23,7 @@ export enum AppRoute {
ADMIN_LIBRARY_MANAGEMENT = '/admin/library-management',
ADMIN_SETTINGS = '/admin/system-settings',
ADMIN_STATS = '/admin/server-status',
ADMIN_JOBS = '/admin/jobs-status',
ADMIN_QUEUES = '/admin/queues',
ADMIN_REPAIR = '/admin/repair',
ALBUMS = '/albums',

View File

@@ -4,6 +4,7 @@ import type {
AlbumResponseDto,
LibraryResponseDto,
LoginResponseDto,
QueueResponseDto,
SharedLinkResponseDto,
SystemConfigDto,
UserAdminResponseDto,
@@ -21,6 +22,8 @@ export type Events = {
AlbumDelete: [AlbumResponseDto];
QueueUpdate: [QueueResponseDto];
SharedLinkCreate: [SharedLinkResponseDto];
SharedLinkUpdate: [SharedLinkResponseDto];
SharedLinkDelete: [SharedLinkResponseDto];

View File

@@ -0,0 +1,45 @@
import { eventManager } from '$lib/managers/event-manager.svelte';
import type { QueueSnapshot } from '$lib/types';
import { getQueues, type QueueResponseDto } from '@immich/sdk';
import { DateTime } from 'luxon';
export class QueueManager {
#snapshots = $state<QueueSnapshot[]>([]);
#queues: QueueResponseDto[] = $derived(this.#snapshots.at(-1)?.snapshot ?? []);
#interval?: ReturnType<typeof setInterval>;
#listenerCount = 0;
get snapshots() {
return this.#snapshots;
}
get queues() {
return this.#queues;
}
constructor() {
eventManager.on('QueueUpdate', () => void this.refresh());
}
listen() {
if (!this.#interval) {
this.#interval = setInterval(() => void this.refresh(true), 3000);
}
this.#listenerCount++;
void this.refresh();
return () => this.#listenerCount--;
}
async refresh(tick = false) {
this.#snapshots.push({
timestamp: DateTime.now().toMillis(),
snapshot: this.#listenerCount > 0 || !tick ? await getQueues().catch(() => undefined) : undefined,
});
this.#snapshots = this.#snapshots.slice(-30);
}
}
export const queueManager = new QueueManager();

View File

@@ -89,7 +89,7 @@
<Text size="small" class="mt-2" color="muted">
{$t('admin.note_apply_storage_label_previous_assets')}
<Link href={AppRoute.ADMIN_JOBS}>
<Link href={AppRoute.ADMIN_QUEUES}>
{$t('admin.storage_template_migration_job')}
</Link>
</Text>

View File

@@ -0,0 +1,246 @@
import { goto } from '$app/navigation';
import { AppRoute } from '$lib/constants';
import { eventManager } from '$lib/managers/event-manager.svelte';
import JobCreateModal from '$lib/modals/JobCreateModal.svelte';
import { user } from '$lib/stores/user.store';
import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
import { emptyQueue, getQueue, QueueName, updateQueue, type QueueResponseDto } from '@immich/sdk';
import { modalManager, toastManager, type ActionItem, type IconLike } from '@immich/ui';
import {
mdiClose,
mdiCog,
mdiContentDuplicate,
mdiDatabaseOutline,
mdiFaceRecognition,
mdiFileJpgBox,
mdiFileXmlBox,
mdiFolderMove,
mdiImageSearch,
mdiLibraryShelves,
mdiOcr,
mdiPause,
mdiPlay,
mdiPlus,
mdiStateMachine,
mdiSync,
mdiTable,
mdiTagFaces,
mdiTrashCanOutline,
mdiTrayFull,
mdiVideo,
} from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n';
import { get } from 'svelte/store';
type QueueItem = {
icon: IconLike;
title: string;
subtitle?: string;
};
export const getQueuesActions = ($t: MessageFormatter) => {
const ViewQueues: ActionItem = {
title: $t('admin.queues'),
description: $t('admin.queues_page_description'),
icon: mdiSync,
type: $t('page'),
isGlobal: true,
$if: () => get(user)?.isAdmin,
onAction: () => goto(AppRoute.ADMIN_QUEUES),
};
const CreateJob: ActionItem = {
icon: mdiPlus,
title: $t('admin.create_job'),
type: $t('command'),
shortcuts: { shift: true, key: 'n' },
onAction: async () => {
await modalManager.show(JobCreateModal, {});
},
};
const ManageConcurrency: ActionItem = {
icon: mdiCog,
title: $t('admin.manage_concurrency'),
description: $t('admin.manage_concurrency_description'),
type: $t('page'),
onAction: () => goto(`${AppRoute.ADMIN_SETTINGS}?isOpen=job`),
};
return { ViewQueues, ManageConcurrency, CreateJob };
};
export const getQueueActions = ($t: MessageFormatter, queue: QueueResponseDto) => {
const Pause: ActionItem = {
icon: mdiPause,
title: $t('pause'),
$if: () => !queue.isPaused,
onAction: () => handlePauseQueue(queue),
};
const Resume: ActionItem = {
icon: mdiPlay,
title: $t('resume'),
$if: () => queue.isPaused,
onAction: () => handleResumeQueue(queue),
};
const Empty: ActionItem = {
icon: mdiClose,
title: $t('clear'),
onAction: () => handleEmptyQueue(queue),
};
const RemoveFailedJobs: ActionItem = {
icon: mdiTrashCanOutline,
color: 'danger',
title: $t('admin.remove_failed_jobs'),
onAction: () => handleRemoveFailedJobs(queue),
};
return { Pause, Resume, Empty, RemoveFailedJobs };
};
export const handlePauseQueue = async (queue: QueueResponseDto) => {
const response = await updateQueue({ name: queue.name, queueUpdateDto: { isPaused: true } });
eventManager.emit('QueueUpdate', response);
};
export const handleResumeQueue = async (queue: QueueResponseDto) => {
const response = await updateQueue({ name: queue.name, queueUpdateDto: { isPaused: false } });
eventManager.emit('QueueUpdate', response);
};
export const handleEmptyQueue = async (queue: QueueResponseDto) => {
const $t = await getFormatter();
const item = asQueueItem($t, queue);
try {
await emptyQueue({ name: queue.name, queueDeleteDto: { failed: false } });
const response = await getQueue({ name: queue.name });
eventManager.emit('QueueUpdate', response);
toastManager.success($t('admin.cleared_jobs', { values: { job: item.title } }));
} catch (error) {
handleError(error, $t('errors.something_went_wrong'));
}
};
const handleRemoveFailedJobs = async (queue: QueueResponseDto) => {
const $t = await getFormatter();
try {
await emptyQueue({ name: queue.name, queueDeleteDto: { failed: true } });
const response = await getQueue({ name: queue.name });
eventManager.emit('QueueUpdate', response);
toastManager.success();
} catch (error) {
handleError(error, $t('errors.something_went_wrong'));
}
};
export const asQueueItem = ($t: MessageFormatter, queue: { name: QueueName }): QueueItem => {
// TODO merge this mapping with data from QueuePanel.svelte
const items: Record<QueueName, QueueItem> = {
[QueueName.ThumbnailGeneration]: {
icon: mdiFileJpgBox,
title: $t('admin.thumbnail_generation_job'),
subtitle: $t('admin.thumbnail_generation_job_description'),
},
[QueueName.MetadataExtraction]: {
icon: mdiTable,
title: $t('admin.metadata_extraction_job'),
subtitle: $t('admin.metadata_extraction_job_description'),
},
[QueueName.Library]: {
icon: mdiLibraryShelves,
title: $t('external_libraries'),
subtitle: $t('admin.library_tasks_description'),
},
[QueueName.Sidecar]: {
title: $t('admin.sidecar_job'),
icon: mdiFileXmlBox,
subtitle: $t('admin.sidecar_job_description'),
},
[QueueName.SmartSearch]: {
icon: mdiImageSearch,
title: $t('admin.machine_learning_smart_search'),
subtitle: $t('admin.smart_search_job_description'),
},
[QueueName.DuplicateDetection]: {
icon: mdiContentDuplicate,
title: $t('admin.machine_learning_duplicate_detection'),
subtitle: $t('admin.duplicate_detection_job_description'),
},
[QueueName.FaceDetection]: {
icon: mdiFaceRecognition,
title: $t('admin.face_detection'),
subtitle: $t('admin.face_detection_description'),
},
[QueueName.FacialRecognition]: {
icon: mdiTagFaces,
title: $t('admin.machine_learning_facial_recognition'),
subtitle: $t('admin.facial_recognition_job_description'),
},
[QueueName.Ocr]: {
icon: mdiOcr,
title: $t('admin.machine_learning_ocr'),
subtitle: $t('admin.ocr_job_description'),
},
[QueueName.VideoConversion]: {
icon: mdiVideo,
title: $t('admin.video_conversion_job'),
subtitle: $t('admin.video_conversion_job_description'),
},
[QueueName.StorageTemplateMigration]: {
icon: mdiFolderMove,
title: $t('admin.storage_template_migration'),
},
[QueueName.Migration]: {
icon: mdiFolderMove,
title: $t('admin.migration_job'),
subtitle: $t('admin.migration_job_description'),
},
[QueueName.BackgroundTask]: {
icon: mdiTrayFull,
title: $t('admin.background_task_job'),
},
[QueueName.Search]: {
icon: '',
title: $t('search'),
},
[QueueName.Notifications]: {
icon: '',
title: $t('notifications'),
},
[QueueName.BackupDatabase]: {
icon: mdiDatabaseOutline,
title: $t('admin.backup_database'),
},
[QueueName.Workflow]: {
icon: mdiStateMachine,
title: $t('workflow'),
},
};
return items[queue.name];
};
export const asQueueSlug = (name: QueueName) => {
return name.replaceAll(/[A-Z]/g, (m) => '-' + m.toLowerCase());
};
export const fromQueueSlug = (slug: string): QueueName | undefined => {
const name = slug.replaceAll(/-([a-z])/g, (_, c) => c.toUpperCase());
if (Object.values(QueueName).includes(name as QueueName)) {
return name as QueueName;
}
};
export const getQueueDetailUrl = (queue: QueueResponseDto) => {
return `${AppRoute.ADMIN_QUEUES}/${asQueueSlug(queue.name)}`;
};
export const handleViewQueue = (queue: QueueResponseDto) => {
return goto(getQueueDetailUrl(queue));
};

View File

@@ -2,16 +2,16 @@
import BottomInfo from '$lib/components/shared-components/side-bar/bottom-info.svelte';
import { AppRoute } from '$lib/constants';
import { NavbarItem } from '@immich/ui';
import { mdiAccountMultipleOutline, mdiBookshelf, mdiCog, mdiServer, mdiSync } from '@mdi/js';
import { mdiAccountMultipleOutline, mdiBookshelf, mdiCog, mdiServer, mdiTrayFull } from '@mdi/js';
import { t } from 'svelte-i18n';
</script>
<div class="h-full flex flex-col justify-between gap-2">
<div class="flex flex-col pt-8 pe-4 gap-1">
<NavbarItem title={$t('users')} href={AppRoute.ADMIN_USERS} icon={mdiAccountMultipleOutline} />
<NavbarItem title={$t('jobs')} href={AppRoute.ADMIN_JOBS} icon={mdiSync} />
<NavbarItem title={$t('settings')} href={AppRoute.ADMIN_SETTINGS} icon={mdiCog} />
<NavbarItem title={$t('external_libraries')} href={AppRoute.ADMIN_LIBRARY_MANAGEMENT} icon={mdiBookshelf} />
<NavbarItem title={$t('admin.queues')} href={AppRoute.ADMIN_QUEUES} icon={mdiTrayFull} />
<NavbarItem title={$t('settings')} href={AppRoute.ADMIN_SETTINGS} icon={mdiCog} />
<NavbarItem title={$t('server_stats')} href={AppRoute.ADMIN_STATS} icon={mdiServer} />
</div>

View File

@@ -1,4 +1,4 @@
import type { ServerVersionResponseDto } from '@immich/sdk';
import type { QueueResponseDto, ServerVersionResponseDto } from '@immich/sdk';
export interface ReleaseEvent {
isAvailable: boolean;
@@ -7,3 +7,5 @@ export interface ReleaseEvent {
serverVersion: ServerVersionResponseDto;
releaseVersion: ServerVersionResponseDto;
}
export type QueueSnapshot = { timestamp: number; snapshot?: QueueResponseDto[] };

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { afterNavigate, goto, invalidateAll } from '$app/navigation';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import UserPageLayout, { headerId } from '$lib/components/layouts/user-page-layout.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
import Breadcrumbs from '$lib/components/shared-components/tree/breadcrumbs.svelte';
@@ -20,6 +20,7 @@
import TagAction from '$lib/components/timeline/actions/TagAction.svelte';
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import { AppRoute, QueryParameter } from '$lib/constants';
import SkipLink from '$lib/elements/SkipLink.svelte';
import type { Viewport } from '$lib/managers/timeline-manager/types';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { foldersStore } from '$lib/stores/folders.svelte';
@@ -78,6 +79,7 @@
<UserPageLayout title={data.meta.title}>
{#snippet sidebar()}
<Sidebar>
<SkipLink target={`#${headerId}`} text={$t('skip_to_folders')} breakpoint="md" />
<section>
<div class="uppercase text-xs ps-4 mb-2 dark:text-white">{$t('explorer')}</div>
<div class="h-full">

View File

@@ -1,12 +1,13 @@
<script lang="ts">
import { goto } from '$app/navigation';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import UserPageLayout, { headerId } from '$lib/components/layouts/user-page-layout.svelte';
import Breadcrumbs from '$lib/components/shared-components/tree/breadcrumbs.svelte';
import TreeItemThumbnails from '$lib/components/shared-components/tree/tree-item-thumbnails.svelte';
import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte';
import Sidebar from '$lib/components/sidebar/sidebar.svelte';
import Timeline from '$lib/components/timeline/Timeline.svelte';
import { AppRoute, AssetAction, QueryParameter } from '$lib/constants';
import SkipLink from '$lib/elements/SkipLink.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import TagCreateModal from '$lib/modals/TagCreateModal.svelte';
import TagEditModal from '$lib/modals/TagEditModal.svelte';
@@ -83,6 +84,7 @@
<UserPageLayout title={data.meta.title}>
{#snippet sidebar()}
<Sidebar>
<SkipLink target={`#${headerId}`} text={$t('skip_to_tags')} breakpoint="md" />
<section>
<div class="uppercase text-xs ps-4 mb-2 dark:text-white">{$t('explorer')}</div>
<div class="h-full">

View File

@@ -14,6 +14,7 @@
import { themeManager } from '$lib/managers/theme-manager.svelte';
import ServerRestartingModal from '$lib/modals/ServerRestartingModal.svelte';
import VersionAnnouncementModal from '$lib/modals/VersionAnnouncementModal.svelte';
import { getQueuesActions } from '$lib/services/queue.service';
import { user } from '$lib/stores/user.store';
import { closeWebsocketConnection, openWebsocketConnection, websocketStore } from '$lib/stores/websocket';
import type { ReleaseEvent } from '$lib/types';
@@ -21,7 +22,7 @@
import { maintenanceShouldRedirect } from '$lib/utils/maintenance';
import { isAssetViewerRoute } from '$lib/utils/navigation';
import { CommandPaletteContext, modalManager, setTranslations, type ActionItem } from '@immich/ui';
import { mdiAccountMultipleOutline, mdiBookshelf, mdiCog, mdiServer, mdiSync, mdiThemeLightDark } from '@mdi/js';
import { mdiAccountMultipleOutline, mdiBookshelf, mdiCog, mdiServer, mdiThemeLightDark } from '@mdi/js';
import { onMount, type Snippet } from 'svelte';
import { t } from 'svelte-i18n';
import '../app.css';
@@ -142,15 +143,9 @@
icon: mdiAccountMultipleOutline,
onAction: () => goto(AppRoute.ADMIN_USERS),
},
{
title: $t('jobs'),
description: $t('admin.jobs_page_description'),
icon: mdiSync,
onAction: () => goto(AppRoute.ADMIN_JOBS),
},
{
title: $t('settings'),
description: $t('admin.jobs_page_description'),
description: $t('admin.settings_page_description'),
icon: mdiCog,
onAction: () => goto(AppRoute.ADMIN_SETTINGS),
},
@@ -168,7 +163,7 @@
},
].map((route) => ({ ...route, type: $t('page'), isGlobal: true, $if: () => $user?.isAdmin }));
const commands = $derived([...userCommands, ...adminCommands]);
const commands = $derived([...userCommands, ...adminCommands, ...Object.values(getQueuesActions($t))]);
</script>
<OnEvents {onReleaseEvent} />

View File

@@ -1,116 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import JobsPanel from '$lib/components/jobs/JobsPanel.svelte';
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
import { AppRoute } from '$lib/constants';
import JobCreateModal from '$lib/modals/JobCreateModal.svelte';
import { asyncTimeout } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import {
getQueuesLegacy,
QueueCommand,
QueueName,
runQueueCommandLegacy,
type QueuesResponseLegacyDto,
} from '@immich/sdk';
import { Button, CommandPaletteContext, HStack, modalManager, Text, type ActionItem } from '@immich/ui';
import { mdiCog, mdiPlay, mdiPlus } from '@mdi/js';
import { onDestroy, onMount } from 'svelte';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
interface Props {
data: PageData;
}
let { data }: Props = $props();
let jobs: QueuesResponseLegacyDto | undefined = $state();
let running = true;
const pausedJobs = $derived(
Object.entries(jobs ?? {})
.filter(([_, queue]) => queue.queueStatus?.isPaused)
.map(([name]) => name as QueueName),
);
const handleResumePausedJobs = async () => {
try {
for (const name of pausedJobs) {
await runQueueCommandLegacy({ name, queueCommandDto: { command: QueueCommand.Resume, force: false } });
}
// Refresh jobs status immediately after resuming
jobs = await getQueuesLegacy();
} catch (error) {
handleError(error, $t('admin.failed_job_command', { values: { command: 'resume', job: 'paused jobs' } }));
}
};
const handleCreateJob = () => modalManager.show(JobCreateModal);
const jobConcurrencyLink = `${AppRoute.ADMIN_SETTINGS}?isOpen=job`;
const commands: ActionItem[] = [
{
title: $t('admin.create_job'),
type: $t('command'),
icon: mdiPlus,
onAction: () => void handleCreateJob(),
shortcuts: { shift: true, key: 'n' },
},
{
title: $t('admin.manage_concurrency'),
description: $t('admin.manage_concurrency_description'),
type: $t('page'),
icon: mdiCog,
onAction: () => goto(jobConcurrencyLink),
},
];
onMount(async () => {
while (running) {
jobs = await getQueuesLegacy();
await asyncTimeout(5000);
}
});
onDestroy(() => {
running = false;
});
</script>
<CommandPaletteContext {commands} />
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]}>
{#snippet buttons()}
<HStack gap={0}>
{#if pausedJobs.length > 0}
<Button
leadingIcon={mdiPlay}
onclick={handleResumePausedJobs}
size="small"
variant="ghost"
title={pausedJobs.join(', ')}
>
<Text class="hidden md:block">
{$t('resume_paused_jobs', { values: { count: pausedJobs.length } })}
</Text>
</Button>
{/if}
<Button leadingIcon={mdiPlus} onclick={handleCreateJob} size="small" variant="ghost" color="secondary">
<Text class="hidden md:block">{$t('admin.create_job')}</Text>
</Button>
<Button leadingIcon={mdiCog} href={jobConcurrencyLink} size="small" variant="ghost" color="secondary">
<Text class="hidden md:block">{$t('admin.manage_concurrency')}</Text>
</Button>
</HStack>
{/snippet}
<section id="setting-content" class="flex place-content-center sm:mx-4">
<section class="w-full pb-28 sm:w-5/6 md:w-212.5">
{#if jobs}
<JobsPanel {jobs} />
{/if}
</section>
</section>
</AdminPageLayout>

View File

@@ -1,18 +1,5 @@
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getQueuesLegacy } from '@immich/sdk';
import { AppRoute } from '$lib/constants';
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
export const load = (async ({ url }) => {
await authenticate(url, { admin: true });
const jobs = await getQueuesLegacy();
const $t = await getFormatter();
return {
jobs,
meta: {
title: $t('admin.job_status'),
},
};
}) satisfies PageLoad;
export const load = (() => redirect(307, AppRoute.ADMIN_QUEUES)) satisfies PageLoad;

View File

@@ -0,0 +1,83 @@
<script lang="ts">
import HeaderButton from '$lib/components/HeaderButton.svelte';
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import JobsPanel from '$lib/components/QueuePanel.svelte';
import { queueManager } from '$lib/managers/queue-manager.svelte';
import { getQueuesActions } from '$lib/services/queue.service';
import { handleError } from '$lib/utils/handle-error';
import { QueueCommand, runQueueCommandLegacy, type QueueResponseDto } from '@immich/sdk';
import { Button, CommandPaletteContext, HStack, Text, type ActionItem } from '@immich/ui';
import { mdiPlay } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
type Props = {
data: PageData;
};
const { data }: Props = $props();
onMount(() => queueManager.listen());
let queues = $derived<QueueResponseDto[]>(queueManager.queues);
const pausedQueues = $derived(queues.filter(({ isPaused }) => isPaused).map(({ name }) => name));
const handleResumePausedJobs = async () => {
try {
for (const name of pausedQueues) {
await runQueueCommandLegacy({ name, queueCommandDto: { command: QueueCommand.Resume, force: false } });
}
await queueManager.refresh();
} catch (error) {
handleError(error, $t('admin.failed_job_command', { values: { command: 'resume', job: 'paused jobs' } }));
}
};
const { CreateJob, ManageConcurrency } = $derived(getQueuesActions($t));
const commands: ActionItem[] = $derived([CreateJob, ManageConcurrency]);
const onQueueUpdate = (update: QueueResponseDto) => {
queues = queues.map((queue) => {
if (queue.name === update.name) {
return update;
}
return queue;
});
};
</script>
<CommandPaletteContext {commands} />
<OnEvents {onQueueUpdate} />
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]}>
{#snippet buttons()}
<HStack gap={0}>
{#if pausedQueues.length > 0}
<Button
leadingIcon={mdiPlay}
onclick={handleResumePausedJobs}
size="small"
variant="ghost"
title={pausedQueues.join(', ')}
>
<Text class="hidden md:block">
{$t('resume_paused_jobs', { values: { count: pausedQueues.length } })}
</Text>
</Button>
{/if}
<HeaderButton action={CreateJob} />
<HeaderButton action={ManageConcurrency} />
</HStack>
{/snippet}
<section id="setting-content" class="flex place-content-center sm:mx-4">
<section class="w-full pb-28 sm:w-5/6 md:w-212.5">
{#if queues}
<JobsPanel {queues} />
{/if}
</section>
</section>
</AdminPageLayout>

View File

@@ -0,0 +1,18 @@
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getQueues } from '@immich/sdk';
import type { PageLoad } from './$types';
export const load = (async ({ url }) => {
await authenticate(url, { admin: true });
const queues = await getQueues();
const $t = await getFormatter();
return {
queues,
meta: {
title: $t('admin.queues'),
},
};
}) satisfies PageLoad;

View File

@@ -0,0 +1,82 @@
<script lang="ts">
import HeaderButton from '$lib/components/HeaderButton.svelte';
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import QueueGraph from '$lib/components/QueueGraph.svelte';
import { AppRoute } from '$lib/constants';
import { queueManager } from '$lib/managers/queue-manager.svelte';
import { asQueueItem, getQueueActions } from '$lib/services/queue.service';
import { type QueueResponseDto } from '@immich/sdk';
import { Badge, Card, CardBody, CardHeader, CardTitle, Container, Heading, HStack, Icon, Text } from '@immich/ui';
import { mdiClockTimeTwoOutline } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
type Props = {
data: PageData;
};
const { data }: Props = $props();
let queue = $derived(data.queue);
const { Pause, Resume, Empty, RemoveFailedJobs } = $derived(getQueueActions($t, queue));
const item = $derived(asQueueItem($t, queue));
onMount(() => queueManager.listen());
const onQueueUpdate = (update: QueueResponseDto) => {
if (update.name === queue.name) {
queue = update;
}
};
</script>
<OnEvents {onQueueUpdate} />
<AdminPageLayout breadcrumbs={[{ title: $t('admin.queues'), href: AppRoute.ADMIN_QUEUES }, { title: item.title }]}>
{#snippet buttons()}
<HStack gap={0}>
<HeaderButton action={Pause} />
<HeaderButton action={Resume} />
<HeaderButton action={Empty} />
<HeaderButton action={RemoveFailedJobs} />
</HStack>
{/snippet}
<div>
<Container size="large" center>
<div class="mb-1 mt-4 flex items-center gap-2">
<Heading tag="h1" size="large">{item.title}</Heading>
{#if queue.isPaused}
<Badge color="warning">
{$t('paused')}
</Badge>
{/if}
</div>
<Text color="muted" class="mb-4">{item.subtitle}</Text>
<div class="flex gap-1 mb-4">
<Badge>{$t('active_count', { values: { count: queue.statistics.active } })}</Badge>
<Badge>{$t('waiting_count', { values: { count: queue.statistics.waiting } })}</Badge>
{#if queue.statistics.failed > 0}
<Badge color="danger">{$t('failed_count', { values: { count: queue.statistics.failed } })}</Badge>
{/if}
</div>
<div class="mt-8">
<Card color="secondary">
<CardHeader>
<div class="flex items-center gap-2 text-primary">
<Icon icon={mdiClockTimeTwoOutline} size="1.5rem" />
<CardTitle>{$t('admin.jobs_over_time')}</CardTitle>
</div>
</CardHeader>
<CardBody>
<QueueGraph {queue} class="h-[300px]" />
</CardBody>
</Card>
</div>
</Container>
</div>
</AdminPageLayout>

View File

@@ -0,0 +1,31 @@
import { AppRoute } from '$lib/constants';
import { fromQueueSlug } from '$lib/services/queue.service';
import { authenticate, requestServerInfo } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getQueue, getQueueJobs, QueueJobStatus } from '@immich/sdk';
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
export const load = (async ({ params, url }) => {
await authenticate(url, { admin: true });
await requestServerInfo();
const name = fromQueueSlug(params.name);
if (!name) {
redirect(302, AppRoute.ADMIN_QUEUES);
}
const [queue, failedJobs] = await Promise.all([
getQueue({ name }),
getQueueJobs({ name, status: [QueueJobStatus.Failed, QueueJobStatus.Paused] }),
]);
const $t = await getFormatter();
return {
queue,
failedJobs,
meta: {
title: $t('admin.queue_details'),
},
};
}) satisfies PageLoad;

View File

@@ -28,6 +28,8 @@ export const assetFactory = Sync.makeFactory<AssetResponseDto>({
isOffline: Sync.each(() => faker.datatype.boolean()),
hasMetadata: Sync.each(() => faker.datatype.boolean()),
visibility: AssetVisibility.Timeline,
width: faker.number.int({ min: 100, max: 1000 }),
height: faker.number.int({ min: 100, max: 1000 }),
});
export const timelineAssetFactory = Sync.makeFactory<TimelineAsset>({