mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 05:22:15 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e176917e8d | |||
| edbae35ea0 |
@@ -49,7 +49,7 @@ Immich requires [**Docker**](https://docs.docker.com/get-started/get-docker/) wi
|
||||
The Compose plugin will be installed by both Docker Engine and Desktop by following the linked installation guides; it can also be [separately installed](https://docs.docker.com/compose/install/).
|
||||
|
||||
:::note
|
||||
Immich requires the command `docker compose`; the similarly named `docker-compose` is [deprecated](https://docs.docker.com/retired/#docker-compose-v1-replaced-by-compose-v2) and is no longer supported by Immich.
|
||||
Immich requires the command `docker compose`; the similarly named `docker-compose` is [deprecated](https://docs.docker.com/compose/migrate/) and is no longer supported by Immich.
|
||||
:::
|
||||
|
||||
### Special requirements for Windows users
|
||||
|
||||
@@ -146,7 +146,7 @@ class URLSessionManager: NSObject {
|
||||
|
||||
private static func buildSession(delegate: URLSessionManagerDelegate) -> URLSession {
|
||||
let config = URLSessionConfiguration.default
|
||||
config.urlCache = urlCache
|
||||
// config.urlCache = urlCache
|
||||
config.httpCookieStorage = cookieStorage
|
||||
config.httpMaximumConnectionsPerHost = 64
|
||||
config.timeoutIntervalForRequest = 60
|
||||
|
||||
@@ -119,7 +119,9 @@
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
<string>No</string>
|
||||
<true/>
|
||||
<key>UIFileSharingEnabled</key>
|
||||
<true/>
|
||||
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
|
||||
@@ -147,7 +147,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
}
|
||||
|
||||
void _onAssetInit(Duration timeStamp) {
|
||||
_preloader.preload(widget.initialIndex, context.sizeData);
|
||||
// _preloader.preload(widget.initialIndex, context.sizeData);
|
||||
_handleCasting();
|
||||
}
|
||||
|
||||
@@ -158,7 +158,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
if (asset == null) return;
|
||||
|
||||
AssetViewer._setAsset(ref, asset);
|
||||
_preloader.preload(index, context.sizeData);
|
||||
// _preloader.preload(index, context.sizeData);
|
||||
_handleCasting();
|
||||
_stackChildrenKeepAlive?.close();
|
||||
_stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive();
|
||||
|
||||
@@ -8,11 +8,16 @@ import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumb_hash_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
|
||||
import 'package:immich_mobile/utils/image_load_histogram.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
final log = Logger('ThumbnailWidget');
|
||||
|
||||
enum ThumbhashMode { enabled, disabled, only }
|
||||
enum ImageType { thumbnail }
|
||||
|
||||
final remoteImageHistogram = Histogram<ImageType>(maxSamples: 8192, values: ImageType.values);
|
||||
|
||||
int thumbnailId = 0;
|
||||
|
||||
class Thumbnail extends StatefulWidget {
|
||||
final ImageProvider? imageProvider;
|
||||
@@ -111,8 +116,11 @@ class _ThumbnailState extends State<Thumbnail> with SingleTickerProviderStateMix
|
||||
if (imageProvider == null) return;
|
||||
|
||||
final imageStream = _imageStream = imageProvider.resolve(ImageConfiguration.empty);
|
||||
final stopwatch = Stopwatch();
|
||||
final curThumbnailId = thumbnailId++;
|
||||
final imageStreamListener = _imageStreamListener = ImageStreamListener(
|
||||
(ImageInfo imageInfo, bool synchronousCall) {
|
||||
stopwatch.stop();
|
||||
_stopListeningToThumbhashStream();
|
||||
if (!mounted) {
|
||||
imageInfo.dispose();
|
||||
@@ -123,7 +131,27 @@ class _ThumbnailState extends State<Thumbnail> with SingleTickerProviderStateMix
|
||||
return;
|
||||
}
|
||||
|
||||
if ((synchronousCall && _providerImage == null) || !_isVisible()) {
|
||||
final renderObject = context.findRenderObject() as RenderBox?;
|
||||
final double topLeft;
|
||||
final double bottomRight;
|
||||
final double contextHeight = context.height;
|
||||
if (renderObject == null || !renderObject.attached) {
|
||||
topLeft = double.maxFinite;
|
||||
bottomRight = double.maxFinite;
|
||||
} else {
|
||||
topLeft = renderObject.localToGlobal(Offset.zero).dy;
|
||||
bottomRight = renderObject.localToGlobal(Offset(renderObject.size.width, renderObject.size.height)).dy;
|
||||
}
|
||||
remoteImageHistogram.record(
|
||||
ImageType.thumbnail,
|
||||
stopwatch.elapsedMicroseconds,
|
||||
topLeft.toInt(),
|
||||
bottomRight.toInt(),
|
||||
contextHeight.toInt(),
|
||||
curThumbnailId,
|
||||
);
|
||||
|
||||
if ((synchronousCall && _providerImage == null) || !(topLeft < contextHeight && bottomRight > 0)) {
|
||||
_fadeController.value = 1.0;
|
||||
} else if (_fadeController.isAnimating) {
|
||||
_fadeController.forward();
|
||||
@@ -146,6 +174,7 @@ class _ThumbnailState extends State<Thumbnail> with SingleTickerProviderStateMix
|
||||
_stopListeningToImageStream();
|
||||
},
|
||||
);
|
||||
stopwatch.start();
|
||||
imageStream.addListener(imageStreamListener);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/scheduler.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/events.model.dart';
|
||||
@@ -17,6 +18,7 @@ import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/download_status_floating_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/scrubber.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
|
||||
@@ -140,10 +142,14 @@ class _SliverTimeline extends ConsumerStatefulWidget {
|
||||
ConsumerState createState() => _SliverTimelineState();
|
||||
}
|
||||
|
||||
class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
class _SliverTimelineState extends ConsumerState<_SliverTimeline> with SingleTickerProviderStateMixin {
|
||||
late final ScrollController _scrollController;
|
||||
StreamSubscription? _eventSubscription;
|
||||
|
||||
Ticker? _autoScrollTicker;
|
||||
Duration _lastTickTime = Duration.zero;
|
||||
static const _autoScrollVelocity = 4800.0; // pixels per second
|
||||
|
||||
// Drag selection state
|
||||
bool _dragging = false;
|
||||
TimelineAssetIndex? _dragAnchorIndex;
|
||||
@@ -246,11 +252,52 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stopAutoScroll();
|
||||
_scrollController.dispose();
|
||||
_eventSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _toggleAutoScroll() {
|
||||
if (_autoScrollTicker?.isActive ?? false) {
|
||||
_stopAutoScroll();
|
||||
} else {
|
||||
_startAutoScroll();
|
||||
}
|
||||
}
|
||||
|
||||
void _startAutoScroll() {
|
||||
_lastTickTime = Duration.zero;
|
||||
_autoScrollTicker = createTicker(_onAutoScrollTick)..start();
|
||||
}
|
||||
|
||||
void _stopAutoScroll() {
|
||||
_autoScrollTicker?.stop();
|
||||
_autoScrollTicker?.dispose();
|
||||
_autoScrollTicker = null;
|
||||
}
|
||||
|
||||
void _onAutoScrollTick(Duration elapsed) {
|
||||
if (_lastTickTime == Duration.zero) {
|
||||
_lastTickTime = elapsed;
|
||||
return;
|
||||
}
|
||||
|
||||
final deltaSeconds = (elapsed - _lastTickTime).inMicroseconds / 1000000.0;
|
||||
_lastTickTime = elapsed;
|
||||
|
||||
final newOffset = _scrollController.offset + (_autoScrollVelocity * deltaSeconds);
|
||||
final maxOffset = _scrollController.position.maxScrollExtent;
|
||||
if (newOffset >= maxOffset || remoteImageHistogram.count(ImageType.thumbnail) >= remoteImageHistogram.maxSamples) {
|
||||
_scrollController.jumpTo(newOffset.clamp(0, maxOffset));
|
||||
_stopAutoScroll();
|
||||
remoteImageHistogram.logAll();
|
||||
remoteImageHistogram.save();
|
||||
} else {
|
||||
_scrollController.jumpTo(newOffset);
|
||||
}
|
||||
}
|
||||
|
||||
void _scrollToDate(DateTime date) {
|
||||
final asyncSegments = ref.read(timelineSegmentProvider);
|
||||
asyncSegments.whenData((segments) {
|
||||
@@ -434,6 +481,16 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
controller: _scrollController,
|
||||
child: RawGestureDetector(
|
||||
gestures: {
|
||||
SerialTapGestureRecognizer: GestureRecognizerFactoryWithHandlers<SerialTapGestureRecognizer>(
|
||||
() => SerialTapGestureRecognizer(),
|
||||
(SerialTapGestureRecognizer tap) {
|
||||
tap.onSerialTapDown = (details) {
|
||||
if (details.count == 3) {
|
||||
_toggleAutoScroll();
|
||||
}
|
||||
};
|
||||
},
|
||||
),
|
||||
CustomScaleGestureRecognizer: GestureRecognizerFactoryWithHandlers<CustomScaleGestureRecognizer>(
|
||||
() => CustomScaleGestureRecognizer(),
|
||||
(CustomScaleGestureRecognizer scale) {
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
/// Ring buffer histogram for performance profiling.
|
||||
class Histogram<T extends Enum> {
|
||||
final int _stride;
|
||||
final int _strideMask;
|
||||
final List<T> _values;
|
||||
final Int64List _counts;
|
||||
final Int64List _data;
|
||||
final Stopwatch _clock;
|
||||
static final _log = Logger('Histogram');
|
||||
|
||||
Histogram({required int maxSamples, required List<T> values})
|
||||
: assert(maxSamples & (maxSamples - 1) == 0, 'maxSamples must be power of 2'),
|
||||
_stride = maxSamples,
|
||||
_strideMask = maxSamples - 1,
|
||||
_values = values,
|
||||
_counts = Int64List(values.length),
|
||||
_data = Int64List(maxSamples * values.length * 6),
|
||||
_clock = Stopwatch()..start();
|
||||
|
||||
@pragma("vm:prefer-inline")
|
||||
@pragma("vm:unsafe:no-bounds-checks")
|
||||
void record(T type, int microseconds, int topLeft, int bottomRight, int contextHeight, int id) {
|
||||
final i = type.index;
|
||||
final count = _counts[i];
|
||||
final slot = count & _strideMask;
|
||||
|
||||
final offset = (i * _stride + slot) * 6;
|
||||
_data[offset] = microseconds;
|
||||
_data[offset + 1] = _clock.elapsedMicroseconds;
|
||||
_data[offset + 2] = topLeft;
|
||||
_data[offset + 3] = bottomRight;
|
||||
_data[offset + 4] = contextHeight;
|
||||
_data[offset + 5] = id;
|
||||
_counts[i] = count + 1;
|
||||
}
|
||||
|
||||
int count(T type) => _counts[type.index].clamp(0, _stride);
|
||||
|
||||
int get maxSamples => _stride;
|
||||
|
||||
@pragma("vm:unsafe:no-bounds-checks")
|
||||
void log(T type) {
|
||||
final index = type.index;
|
||||
final total = _counts[index];
|
||||
final count = min(total, _stride);
|
||||
if (count == 0) return;
|
||||
|
||||
final baseOffset = index * _stride * 6;
|
||||
final scratch = Int64List(count);
|
||||
|
||||
for (int i = 0; i < count; i++) {
|
||||
scratch[i] = _data[baseOffset + i * 6];
|
||||
}
|
||||
scratch.sort();
|
||||
|
||||
int sum = 0;
|
||||
for (int i = 0; i < count; i++) {
|
||||
sum += scratch[i];
|
||||
}
|
||||
|
||||
_log.info(
|
||||
'${type.name} (n=$total, sampled=$count) - '
|
||||
'Avg: ${(sum / count / 1000.0).toStringAsFixed(2)}ms, '
|
||||
'Min: ${(scratch[0] / 1000.0).toStringAsFixed(2)}ms, '
|
||||
'Max: ${(scratch[count - 1] / 1000.0).toStringAsFixed(2)}ms, '
|
||||
'P25: ${(_percentile(scratch, count, 0.25) / 1000.0).toStringAsFixed(2)}ms, '
|
||||
'P50: ${(_percentile(scratch, count, 0.50) / 1000.0).toStringAsFixed(2)}ms, '
|
||||
'P75: ${(_percentile(scratch, count, 0.75) / 1000.0).toStringAsFixed(2)}ms, '
|
||||
'P90: ${(_percentile(scratch, count, 0.90) / 1000.0).toStringAsFixed(2)}ms, '
|
||||
'P95: ${(_percentile(scratch, count, 0.95) / 1000.0).toStringAsFixed(2)}ms, '
|
||||
'P99: ${(_percentile(scratch, count, 0.99) / 1000.0).toStringAsFixed(2)}ms',
|
||||
);
|
||||
}
|
||||
|
||||
void logAll() {
|
||||
for (final value in _values) {
|
||||
log(value);
|
||||
}
|
||||
}
|
||||
|
||||
@pragma("vm:unsafe:no-bounds-checks")
|
||||
(Int64List, Int64List, Int64List, Int64List, Int64List, Int64List) getSamples(T type) {
|
||||
final index = type.index;
|
||||
final count = min(_counts[index], _stride);
|
||||
final samples = Int64List(count);
|
||||
final timestamps = Int64List(count);
|
||||
final topLeft = Int64List(count);
|
||||
final bottomRight = Int64List(count);
|
||||
final contextHeight = Int64List(count);
|
||||
final id = Int64List(count);
|
||||
|
||||
final baseOffset = index * _stride * 6;
|
||||
for (int i = 0; i < count; i++) {
|
||||
samples[i] = _data[baseOffset + i * 6];
|
||||
timestamps[i] = _data[baseOffset + i * 6 + 1];
|
||||
topLeft[i] = _data[baseOffset + i * 6 + 2];
|
||||
bottomRight[i] = _data[baseOffset + i * 6 + 3];
|
||||
contextHeight[i] = _data[baseOffset + i * 6 + 4];
|
||||
id[i] = _data[baseOffset + i * 6 + 5];
|
||||
}
|
||||
return (samples, timestamps, topLeft, bottomRight, contextHeight, id);
|
||||
}
|
||||
|
||||
@pragma("vm:unsafe:no-bounds-checks")
|
||||
Future<File> save({bool share = true}) async {
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final timestamp = DateTime.now().toIso8601String().replaceAll(':', '-');
|
||||
final file = File('${dir.path}/samples_$timestamp.json');
|
||||
|
||||
final data = {};
|
||||
for (int i = 0; i < _counts.length; i++) {
|
||||
final name = _values[i].name;
|
||||
final (samples, timestamps, topLeft, bottomRight, contextHeight, id) = getSamples(_values[i]);
|
||||
data['${name}_us'] = samples;
|
||||
data['${name}_ts'] = timestamps;
|
||||
data['${name}_top_left'] = topLeft;
|
||||
data['${name}_bottom_right'] = bottomRight;
|
||||
data['${name}_context_height'] = contextHeight;
|
||||
data['${name}_id'] = id;
|
||||
}
|
||||
data['timestamp'] = DateTime.now().toIso8601String();
|
||||
await file.writeAsString(jsonEncode(data));
|
||||
_log.info('Saved samples to ${file.path}');
|
||||
|
||||
if (share) {
|
||||
await Share.shareXFiles([XFile(file.path)]);
|
||||
}
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
void reset(T type) {
|
||||
_counts[type.index] = 0;
|
||||
}
|
||||
|
||||
void resetAll() {
|
||||
_counts.fillRange(0, _counts.length, 0);
|
||||
}
|
||||
|
||||
@pragma("vm:prefer-inline")
|
||||
int _percentile(Int64List sorted, int count, double p) {
|
||||
final idx = ((count - 1) * p).round();
|
||||
return sorted[idx];
|
||||
}
|
||||
}
|
||||
@@ -195,19 +195,13 @@ where
|
||||
"asset_face"."id" = $2
|
||||
|
||||
-- PersonRepository.getByName
|
||||
with
|
||||
"similarity_threshold" as (
|
||||
select
|
||||
set_config('pg_trgm.word_similarity_threshold', '0.5', true) as "thresh"
|
||||
)
|
||||
select
|
||||
"person".*
|
||||
from
|
||||
"similarity_threshold",
|
||||
"person"
|
||||
where
|
||||
"person"."ownerId" = $1
|
||||
and f_unaccent ("person"."name") %> f_unaccent ($2)
|
||||
and f_unaccent ("person"."name") %>> f_unaccent ($2)
|
||||
order by
|
||||
f_unaccent ("person"."name") <->>> f_unaccent ($3)
|
||||
limit
|
||||
|
||||
@@ -310,13 +310,10 @@ export class PersonRepository {
|
||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING, { withHidden: true }] })
|
||||
getByName(userId: string, personName: string, { withHidden }: PersonNameSearchOptions) {
|
||||
return this.db
|
||||
.with('similarity_threshold', (db) =>
|
||||
db.selectNoFrom(sql`set_config('pg_trgm.word_similarity_threshold', '0.5', true)`.as('thresh')),
|
||||
)
|
||||
.selectFrom(['similarity_threshold', 'person'])
|
||||
.selectFrom('person')
|
||||
.selectAll('person')
|
||||
.where('person.ownerId', '=', userId)
|
||||
.where(() => sql`f_unaccent("person"."name") %> f_unaccent(${personName})`)
|
||||
.where(() => sql`f_unaccent("person"."name") %>> f_unaccent(${personName})`)
|
||||
.orderBy(sql`f_unaccent("person"."name") <->>> f_unaccent(${personName})`)
|
||||
.limit(100)
|
||||
.$if(!withHidden, (qb) => qb.where('person.isHidden', '=', false))
|
||||
|
||||
@@ -265,11 +265,13 @@ export class AlbumService extends BaseService {
|
||||
}
|
||||
|
||||
async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
|
||||
await this.requireAccess({ auth, permission: Permission.AlbumAssetDelete, ids: [id] });
|
||||
|
||||
const album = await this.findOrFail(id, { withAssets: false });
|
||||
const results = await removeAssets(
|
||||
auth,
|
||||
{ access: this.accessRepository, bulk: this.albumRepository },
|
||||
{ parentId: id, assetIds: dto.ids, canAlwaysRemove: Permission.AlbumUpdate },
|
||||
{ parentId: id, assetIds: dto.ids, canAlwaysRemove: Permission.AlbumDelete },
|
||||
);
|
||||
|
||||
const removedIds = results.filter(({ success }) => success).map(({ id }) => id);
|
||||
|
||||
@@ -223,6 +223,16 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe
|
||||
return setUnion(isOwner, isShared);
|
||||
}
|
||||
|
||||
case Permission.AlbumAssetDelete: {
|
||||
const isOwner = await access.album.checkOwnerAccess(auth.user.id, ids);
|
||||
const isShared = await access.album.checkSharedAlbumAccess(
|
||||
auth.user.id,
|
||||
setDifference(ids, isOwner),
|
||||
AlbumUserRole.Editor,
|
||||
);
|
||||
return setUnion(isOwner, isShared);
|
||||
}
|
||||
|
||||
case Permission.AssetUpload: {
|
||||
return ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set<string>();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user