mirror of
https://github.com/immich-app/immich.git
synced 2026-07-08 13:27:46 -07:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 41a2fa8a44 |
@@ -4,7 +4,6 @@ import {
|
|||||||
AssetVisibility,
|
AssetVisibility,
|
||||||
deleteAssets,
|
deleteAssets,
|
||||||
LoginResponseDto,
|
LoginResponseDto,
|
||||||
SharedLinkType,
|
|
||||||
updateAsset,
|
updateAsset,
|
||||||
} from '@immich/sdk';
|
} from '@immich/sdk';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
@@ -358,32 +357,6 @@ describe('/search', () => {
|
|||||||
expect(body.assets.items).toHaveLength(assets.length);
|
expect(body.assets.items).toHaveLength(assets.length);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
it('should reject shared link access without an album filter', async () => {
|
|
||||||
const album = await utils.createAlbum(admin.accessToken, { albumName: 'foo' });
|
|
||||||
const sharedLink = await utils.createSharedLink(admin.accessToken, {
|
|
||||||
type: SharedLinkType.Album,
|
|
||||||
albumId: album.id,
|
|
||||||
});
|
|
||||||
const { status, body } = await request(app).post(`/search/metadata?key=${sharedLink.key}`).send({});
|
|
||||||
expect(status).toBe(400);
|
|
||||||
expect(body).toEqual({ message: 'Shared link access is only allowed in combination with an albumIds filter' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should allow shared link access for albums', async () => {
|
|
||||||
const asset = await utils.createAsset(admin.accessToken);
|
|
||||||
const album = await utils.createAlbum(admin.accessToken, { albumName: 'foo', assetIds: [asset.id] });
|
|
||||||
const sharedLink = await utils.createSharedLink(admin.accessToken, {
|
|
||||||
type: SharedLinkType.Album,
|
|
||||||
albumId: album.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { status, body } = await request(app)
|
|
||||||
.post(`/search/metadata?key=${sharedLink.key}`)
|
|
||||||
.send({ albumIds: [album.id] });
|
|
||||||
expect(status).toBe(200);
|
|
||||||
expect(body.assets.items).toEqual([expect.objectContaining({ id: asset.id })]);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /search/random', () => {
|
describe('POST /search/random', () => {
|
||||||
|
|||||||
@@ -61,17 +61,6 @@ sealed class BaseAsset {
|
|||||||
bool get isLocalOnly => storage == AssetState.local;
|
bool get isLocalOnly => storage == AssetState.local;
|
||||||
bool get isRemoteOnly => storage == AssetState.remote;
|
bool get isRemoteOnly => storage == AssetState.remote;
|
||||||
|
|
||||||
// Same asset even if localId is known on one side but not the other (heroTag isn't stable then)
|
|
||||||
bool refersToSameAsset(BaseAsset other) {
|
|
||||||
if (remoteId != null && other.remoteId != null) {
|
|
||||||
return remoteId == other.remoteId;
|
|
||||||
}
|
|
||||||
if (localId != null && other.localId != null) {
|
|
||||||
return localId == other.localId;
|
|
||||||
}
|
|
||||||
return checksum != null && checksum == other.checksum;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool get isEditable => false;
|
bool get isEditable => false;
|
||||||
|
|
||||||
// Overridden in subclasses
|
// Overridden in subclasses
|
||||||
|
|||||||
@@ -85,8 +85,12 @@ class TimelineFactory {
|
|||||||
TimelineService fromAssetsWithBuckets(List<BaseAsset> assets, TimelineOrigin type) =>
|
TimelineService fromAssetsWithBuckets(List<BaseAsset> assets, TimelineOrigin type) =>
|
||||||
TimelineService(_timelineRepository.fromAssetsWithBuckets(assets, type));
|
TimelineService(_timelineRepository.fromAssetsWithBuckets(assets, type));
|
||||||
|
|
||||||
TimelineService map(List<String> userIds, TimelineMapOptions options) =>
|
/// Creates a TimelineService for serving geographical map queries, such assets within bounded locations
|
||||||
TimelineService(_timelineRepository.map(userIds, options, groupBy));
|
TimelineService geographicMap(
|
||||||
|
List<String> userIds,
|
||||||
|
TimelineMapOptions Function() currentOptions,
|
||||||
|
Stream<TimelineMapOptions> optionsStream,
|
||||||
|
) => TimelineService(_timelineRepository.geographicMap(userIds, currentOptions, optionsStream, groupBy));
|
||||||
}
|
}
|
||||||
|
|
||||||
class TimelineService {
|
class TimelineService {
|
||||||
|
|||||||
@@ -509,9 +509,21 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
|||||||
return query.map((row) => row.toDto()).get();
|
return query.map((row) => row.toDto()).get();
|
||||||
}
|
}
|
||||||
|
|
||||||
TimelineQuery map(List<String> userIds, TimelineMapOptions options, GroupAssetsBy groupBy) => (
|
/// Creates a geographic map query that can dynamically filter on changing [TimelineMapOptions]
|
||||||
bucketSource: () => _watchMapBucket(userIds, options, groupBy: groupBy),
|
/// (most notably the active map bounds)
|
||||||
assetSource: (offset, count) => _getMapBucketAssets(userIds, options, offset: offset, count: count),
|
TimelineQuery geographicMap(
|
||||||
|
List<String> userIds,
|
||||||
|
TimelineMapOptions Function() currentOptions,
|
||||||
|
Stream<TimelineMapOptions> optionsStream,
|
||||||
|
GroupAssetsBy groupBy,
|
||||||
|
) => (
|
||||||
|
bucketSource: () => Stream.value(currentOptions())
|
||||||
|
.followedBy(optionsStream)
|
||||||
|
.switchMap(
|
||||||
|
// Any error would kill the stream for all options; make sure the stream stays alive
|
||||||
|
(options) => _watchMapBucket(userIds, options, groupBy: groupBy).handleError((_) {}),
|
||||||
|
),
|
||||||
|
assetSource: (offset, count) => _getMapBucketAssets(userIds, currentOptions(), offset: offset, count: count),
|
||||||
origin: TimelineOrigin.map,
|
origin: TimelineOrigin.map,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -395,7 +395,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final currentAsset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
|
final currentHeroTag = ref.watch(assetViewerProvider.select((s) => s.currentAsset?.heroTag));
|
||||||
_showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
|
_showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
|
||||||
final stackIndex = ref.watch(assetViewerProvider.select((s) => s.stackIndex));
|
final stackIndex = ref.watch(assetViewerProvider.select((s) => s.stackIndex));
|
||||||
final isPlayingMotionVideo = ref.watch(isPlayingMotionVideoProvider);
|
final isPlayingMotionVideo = ref.watch(isPlayingMotionVideoProvider);
|
||||||
@@ -414,7 +414,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||||||
displayAsset = stackChildren.elementAt(stackIndex);
|
displayAsset = stackChildren.elementAt(stackIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
final isCurrent = currentAsset != null && currentAsset.refersToSameAsset(displayAsset);
|
final isCurrent = currentHeroTag == displayAsset.heroTag;
|
||||||
|
|
||||||
final viewportWidth = MediaQuery.widthOf(context);
|
final viewportWidth = MediaQuery.widthOf(context);
|
||||||
final viewportHeight = MediaQuery.heightOf(context);
|
final viewportHeight = MediaQuery.heightOf(context);
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ class _ScopedMapTimeline extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// TODO: this causes the timeline to switch to flicker to "loading" state and back. This is both janky and inefficient.
|
|
||||||
return ProviderScope(
|
return ProviderScope(
|
||||||
overrides: [
|
overrides: [
|
||||||
timelineServiceProvider.overrideWith((ref) {
|
timelineServiceProvider.overrideWith((ref) {
|
||||||
@@ -44,13 +43,16 @@ class _ScopedMapTimeline extends StatelessWidget {
|
|||||||
throw Exception('User must be logged in to access archive');
|
throw Exception('User must be logged in to access archive');
|
||||||
}
|
}
|
||||||
|
|
||||||
final users = ref.watch(mapStateProvider).withPartners
|
final withPartners = ref.watch(mapStateProvider.select((s) => s.withPartners));
|
||||||
? ref.watch(timelineUsersProvider).valueOrNull ?? [user.id]
|
final users = withPartners ? ref.watch(timelineUsersProvider).valueOrNull ?? [user.id] : [user.id];
|
||||||
: [user.id];
|
|
||||||
|
|
||||||
final timelineService = ref
|
final timelineService = ref
|
||||||
.watch(timelineFactoryProvider)
|
.watch(timelineFactoryProvider)
|
||||||
.map(users, ref.watch(mapStateProvider).toOptions());
|
.geographicMap(
|
||||||
|
users,
|
||||||
|
() => ref.read(mapStateProvider).toOptions(),
|
||||||
|
ref.read(mapStateProvider.notifier).optionsStream,
|
||||||
|
);
|
||||||
ref.onDispose(timelineService.dispose);
|
ref.onDispose(timelineService.dispose);
|
||||||
return timelineService;
|
return timelineService;
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||||
@@ -63,11 +65,16 @@ class MapState {
|
|||||||
class MapStateNotifier extends Notifier<MapState> {
|
class MapStateNotifier extends Notifier<MapState> {
|
||||||
MapStateNotifier();
|
MapStateNotifier();
|
||||||
|
|
||||||
|
final StreamController<TimelineMapOptions> _optionsController = StreamController.broadcast();
|
||||||
|
|
||||||
|
Stream<TimelineMapOptions> get optionsStream => _optionsController.stream;
|
||||||
|
|
||||||
bool setBounds(LatLngBounds bounds) {
|
bool setBounds(LatLngBounds bounds) {
|
||||||
if (state.bounds == bounds) {
|
if (state.bounds == bounds) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
state = state.copyWith(bounds: bounds);
|
state = state.copyWith(bounds: bounds);
|
||||||
|
_optionsController.add(state.toOptions());
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,12 +89,14 @@ class MapStateNotifier extends Notifier<MapState> {
|
|||||||
void switchFavoriteOnly(bool isFavoriteOnly) {
|
void switchFavoriteOnly(bool isFavoriteOnly) {
|
||||||
ref.read(settingsProvider).write(.mapShowFavoriteOnly, isFavoriteOnly);
|
ref.read(settingsProvider).write(.mapShowFavoriteOnly, isFavoriteOnly);
|
||||||
state = state.copyWith(onlyFavorites: isFavoriteOnly);
|
state = state.copyWith(onlyFavorites: isFavoriteOnly);
|
||||||
|
_optionsController.add(state.toOptions());
|
||||||
EventStream.shared.emit(const MapMarkerReloadEvent());
|
EventStream.shared.emit(const MapMarkerReloadEvent());
|
||||||
}
|
}
|
||||||
|
|
||||||
void switchIncludeArchived(bool isIncludeArchived) {
|
void switchIncludeArchived(bool isIncludeArchived) {
|
||||||
ref.read(settingsProvider).write(.mapIncludeArchived, isIncludeArchived);
|
ref.read(settingsProvider).write(.mapIncludeArchived, isIncludeArchived);
|
||||||
state = state.copyWith(includeArchived: isIncludeArchived);
|
state = state.copyWith(includeArchived: isIncludeArchived);
|
||||||
|
_optionsController.add(state.toOptions());
|
||||||
EventStream.shared.emit(const MapMarkerReloadEvent());
|
EventStream.shared.emit(const MapMarkerReloadEvent());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,11 +109,13 @@ class MapStateNotifier extends Notifier<MapState> {
|
|||||||
void setRelativeTime(int relativeDays) {
|
void setRelativeTime(int relativeDays) {
|
||||||
ref.read(settingsProvider).write(.mapRelativeDate, relativeDays);
|
ref.read(settingsProvider).write(.mapRelativeDate, relativeDays);
|
||||||
state = state.copyWith(relativeDays: relativeDays);
|
state = state.copyWith(relativeDays: relativeDays);
|
||||||
|
_optionsController.add(state.toOptions());
|
||||||
EventStream.shared.emit(const MapMarkerReloadEvent());
|
EventStream.shared.emit(const MapMarkerReloadEvent());
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
MapState build() {
|
MapState build() {
|
||||||
|
ref.onDispose(_optionsController.close);
|
||||||
final mapConfig = ref.read(appConfigProvider.select((config) => config.map));
|
final mapConfig = ref.read(appConfigProvider.select((config) => config.map));
|
||||||
return MapState(
|
return MapState(
|
||||||
themeMode: mapConfig.themeMode,
|
themeMode: mapConfig.themeMode,
|
||||||
|
|||||||
@@ -86,14 +86,13 @@ class TimelineStateNotifier extends Notifier<TimelineState> {
|
|||||||
// This provider watches the buckets from the timeline service & args and serves the segments.
|
// This provider watches the buckets from the timeline service & args and serves the segments.
|
||||||
// It should be used only after the timeline service and timeline args provider is overridden
|
// It should be used only after the timeline service and timeline args provider is overridden
|
||||||
final timelineSegmentProvider = StreamProvider.autoDispose<List<Segment>>((ref) async* {
|
final timelineSegmentProvider = StreamProvider.autoDispose<List<Segment>>((ref) async* {
|
||||||
// maxHeight is left out on purpose, a height-only change must not restart the bucket stream
|
final args = ref.watch(timelineArgsProvider);
|
||||||
final (maxWidth, columnCount, spacing, groupByArg) = ref.watch(
|
final columnCount = args.columnCount;
|
||||||
timelineArgsProvider.select((args) => (args.maxWidth, args.columnCount, args.spacing, args.groupBy)),
|
final spacing = args.spacing;
|
||||||
);
|
final availableTileWidth = args.maxWidth - (spacing * (columnCount - 1));
|
||||||
final availableTileWidth = maxWidth - (spacing * (columnCount - 1));
|
|
||||||
final tileExtent = math.max(0, availableTileWidth) / columnCount;
|
final tileExtent = math.max(0, availableTileWidth) / columnCount;
|
||||||
|
|
||||||
final groupBy = groupByArg ?? ref.watch(appConfigProvider.select((config) => config.timeline.groupAssetsBy));
|
final groupBy = args.groupBy ?? ref.watch(appConfigProvider.select((config) => config.timeline.groupAssetsBy));
|
||||||
|
|
||||||
final timelineService = ref.watch(timelineServiceProvider);
|
final timelineService = ref.watch(timelineServiceProvider);
|
||||||
yield* timelineService.watchBuckets().map((buckets) {
|
yield* timelineService.watchBuckets().map((buckets) {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart';
|
|||||||
import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
|
import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
|
||||||
import 'package:immich_mobile/widgets/common/selection_sliver_app_bar.dart';
|
import 'package:immich_mobile/widgets/common/selection_sliver_app_bar.dart';
|
||||||
|
|
||||||
class Timeline extends ConsumerWidget {
|
class Timeline extends StatelessWidget {
|
||||||
const Timeline({
|
const Timeline({
|
||||||
super.key,
|
super.key,
|
||||||
this.topSliverWidget,
|
this.topSliverWidget,
|
||||||
@@ -62,40 +62,35 @@ class Timeline extends ConsumerWidget {
|
|||||||
final Widget? loadingWidget;
|
final Widget? loadingWidget;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context) {
|
||||||
final columnCount = ref.watch(appConfigProvider.select((config) => config.timeline.tilesPerRow));
|
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (_, constraints) {
|
builder: (_, constraints) => ProviderScope(
|
||||||
return ProviderScope(
|
overrides: [
|
||||||
overrides: [
|
timelineArgsProvider.overrideWith(
|
||||||
// overrideWithValue keeps the scoped args in sync with the latest constraints on rebuilds,
|
(ref) => TimelineArgs(
|
||||||
// a function override would stay locked to the first frame's constraints for the whole session
|
maxWidth: constraints.maxWidth,
|
||||||
timelineArgsProvider.overrideWithValue(
|
maxHeight: constraints.maxHeight,
|
||||||
TimelineArgs(
|
columnCount: ref.watch(appConfigProvider.select((config) => config.timeline.tilesPerRow)),
|
||||||
maxWidth: constraints.maxWidth,
|
showStorageIndicator: showStorageIndicator,
|
||||||
maxHeight: constraints.maxHeight,
|
withStack: withStack,
|
||||||
columnCount: columnCount,
|
groupBy: groupBy,
|
||||||
showStorageIndicator: showStorageIndicator,
|
|
||||||
withStack: withStack,
|
|
||||||
groupBy: groupBy,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
if (readOnly) readonlyModeProvider.overrideWith(() => _AlwaysReadOnlyNotifier()),
|
|
||||||
],
|
|
||||||
child: _SliverTimeline(
|
|
||||||
topSliverWidget: topSliverWidget,
|
|
||||||
topSliverWidgetHeight: topSliverWidgetHeight,
|
|
||||||
bottomSliverWidget: bottomSliverWidget,
|
|
||||||
appBar: appBar,
|
|
||||||
bottomSheet: bottomSheet,
|
|
||||||
withScrubber: withScrubber,
|
|
||||||
persistentBottomBar: persistentBottomBar,
|
|
||||||
snapToMonth: snapToMonth,
|
|
||||||
maxWidth: constraints.maxWidth,
|
|
||||||
loadingWidget: loadingWidget,
|
|
||||||
),
|
),
|
||||||
);
|
if (readOnly) readonlyModeProvider.overrideWith(() => _AlwaysReadOnlyNotifier()),
|
||||||
},
|
],
|
||||||
|
child: _SliverTimeline(
|
||||||
|
topSliverWidget: topSliverWidget,
|
||||||
|
topSliverWidgetHeight: topSliverWidgetHeight,
|
||||||
|
bottomSliverWidget: bottomSliverWidget,
|
||||||
|
appBar: appBar,
|
||||||
|
bottomSheet: bottomSheet,
|
||||||
|
withScrubber: withScrubber,
|
||||||
|
persistentBottomBar: persistentBottomBar,
|
||||||
|
snapToMonth: snapToMonth,
|
||||||
|
maxWidth: constraints.maxWidth,
|
||||||
|
loadingWidget: loadingWidget,
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -174,11 +169,13 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBi
|
|||||||
void didUpdateWidget(covariant _SliverTimeline oldWidget) {
|
void didUpdateWidget(covariant _SliverTimeline oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
if (widget.maxWidth != oldWidget.maxWidth) {
|
if (widget.maxWidth != oldWidget.maxWidth) {
|
||||||
// The updated args already regenerate the segments, only remember the scroll position to restore it afterwards
|
final asyncSegments = ref.read(timelineSegmentProvider);
|
||||||
final segments = ref.read(timelineSegmentProvider).valueOrNull;
|
asyncSegments.whenData((segments) {
|
||||||
if (segments != null && _scrollController.hasClients) {
|
final index = _getCurrentAssetIndex(segments);
|
||||||
_restoreAssetIndex = _getCurrentAssetIndex(segments);
|
// Refresh to wait for new segments to be generated with the updated width before restoring the scroll position
|
||||||
}
|
final _ = ref.refresh(timelineArgsProvider);
|
||||||
|
_restoreAssetIndex = index;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -183,7 +183,6 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
|||||||
|
|
||||||
Future<void> saveLocalEndpoint(String url) async {
|
Future<void> saveLocalEndpoint(String url) async {
|
||||||
await _ref.read(settingsProvider).write(.networkLocalEndpoint, url);
|
await _ref.read(settingsProvider).write(.networkLocalEndpoint, url);
|
||||||
await _apiService.updateHeaders();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String? getSavedWifiName() {
|
String? getSavedWifiName() {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import 'package:collection/collection.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
|
||||||
@@ -40,10 +39,5 @@ final timelineUsersProvider = StreamProvider<List<String>>((ref) {
|
|||||||
return Stream.value([]);
|
return Stream.value([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drift re-emits a fresh but content-identical list on unrelated table updates,
|
return ref.watch(timelineRepositoryProvider).watchTimelineUserIds(currentUserId);
|
||||||
// which would dispose and rebuild the timeline service mid-load
|
|
||||||
return ref
|
|
||||||
.watch(timelineRepositoryProvider)
|
|
||||||
.watchTimelineUserIds(currentUserId)
|
|
||||||
.distinct(const ListEquality<String>().equals);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import 'package:immich_mobile/domain/models/settings_key.dart';
|
|||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
|
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
|
||||||
import 'package:immich_mobile/providers/api.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
||||||
import 'package:immich_mobile/widgets/settings/networking_settings/endpoint_input.dart';
|
import 'package:immich_mobile/widgets/settings/networking_settings/endpoint_input.dart';
|
||||||
|
|
||||||
@@ -27,16 +26,13 @@ class ExternalNetworkPreference extends HookConsumerWidget {
|
|||||||
.map((e) => e.url)
|
.map((e) => e.url)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
return ref.read(settingsProvider).write(SettingsKey.networkExternalEndpointList, urls);
|
ref.read(settingsProvider).write(SettingsKey.networkExternalEndpointList, urls);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateValidationStatus(String url, int index, AuxCheckStatus status) async {
|
updateValidationStatus(String url, int index, AuxCheckStatus status) {
|
||||||
entries.value[index] = entries.value[index].copyWith(url: url, status: status);
|
entries.value[index] = entries.value[index].copyWith(url: url, status: status);
|
||||||
|
|
||||||
await saveEndpointList();
|
saveEndpointList();
|
||||||
if (status == AuxCheckStatus.valid) {
|
|
||||||
await ref.read(apiServiceProvider).updateHeaders();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleReorder(int oldIndex, int newIndex) {
|
handleReorder(int oldIndex, int newIndex) {
|
||||||
|
|||||||
Generated
+3
-18
@@ -302,11 +302,7 @@ class SearchApi {
|
|||||||
/// Parameters:
|
/// Parameters:
|
||||||
///
|
///
|
||||||
/// * [MetadataSearchDto] metadataSearchDto (required):
|
/// * [MetadataSearchDto] metadataSearchDto (required):
|
||||||
///
|
Future<Response> searchAssetsWithHttpInfo(MetadataSearchDto metadataSearchDto, { Future<void>? abortTrigger, }) async {
|
||||||
/// * [String] key:
|
|
||||||
///
|
|
||||||
/// * [String] slug:
|
|
||||||
Future<Response> searchAssetsWithHttpInfo(MetadataSearchDto metadataSearchDto, { String? key, String? slug, Future<void>? abortTrigger, }) async {
|
|
||||||
// ignore: prefer_const_declarations
|
// ignore: prefer_const_declarations
|
||||||
final apiPath = r'/search/metadata';
|
final apiPath = r'/search/metadata';
|
||||||
|
|
||||||
@@ -317,13 +313,6 @@ class SearchApi {
|
|||||||
final headerParams = <String, String>{};
|
final headerParams = <String, String>{};
|
||||||
final formParams = <String, String>{};
|
final formParams = <String, String>{};
|
||||||
|
|
||||||
if (key != null) {
|
|
||||||
queryParams.addAll(_queryParams('', 'key', key));
|
|
||||||
}
|
|
||||||
if (slug != null) {
|
|
||||||
queryParams.addAll(_queryParams('', 'slug', slug));
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentTypes = <String>['application/json'];
|
const contentTypes = <String>['application/json'];
|
||||||
|
|
||||||
|
|
||||||
@@ -346,12 +335,8 @@ class SearchApi {
|
|||||||
/// Parameters:
|
/// Parameters:
|
||||||
///
|
///
|
||||||
/// * [MetadataSearchDto] metadataSearchDto (required):
|
/// * [MetadataSearchDto] metadataSearchDto (required):
|
||||||
///
|
Future<SearchResponseDto?> searchAssets(MetadataSearchDto metadataSearchDto, { Future<void>? abortTrigger, }) async {
|
||||||
/// * [String] key:
|
final response = await searchAssetsWithHttpInfo(metadataSearchDto, abortTrigger: abortTrigger,);
|
||||||
///
|
|
||||||
/// * [String] slug:
|
|
||||||
Future<SearchResponseDto?> searchAssets(MetadataSearchDto metadataSearchDto, { String? key, String? slug, Future<void>? abortTrigger, }) async {
|
|
||||||
final response = await searchAssetsWithHttpInfo(metadataSearchDto, key: key, slug: slug, abortTrigger: abortTrigger,);
|
|
||||||
if (response.statusCode >= HttpStatus.badRequest) {
|
if (response.statusCode >= HttpStatus.badRequest) {
|
||||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-8
@@ -309,8 +309,8 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
path: "pkgs/cupertino_http"
|
path: "pkgs/cupertino_http"
|
||||||
ref: "58b03c756b81d16b3975a8ae4b91d25360123bb5"
|
ref: a0a933358517c6d01cff37fc2a2752ee2d744a3c
|
||||||
resolved-ref: "58b03c756b81d16b3975a8ae4b91d25360123bb5"
|
resolved-ref: a0a933358517c6d01cff37fc2a2752ee2d744a3c
|
||||||
url: "https://github.com/mertalev/http"
|
url: "https://github.com/mertalev/http"
|
||||||
source: git
|
source: git
|
||||||
version: "3.0.0-wip"
|
version: "3.0.0-wip"
|
||||||
@@ -1158,13 +1158,12 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "0.5.0"
|
version: "0.5.0"
|
||||||
objective_c:
|
objective_c:
|
||||||
dependency: "direct overridden"
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
path: "pkgs/objective_c"
|
name: objective_c
|
||||||
ref: "2915556701f4734a784222f290a82adb1b101682"
|
sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed"
|
||||||
resolved-ref: "2915556701f4734a784222f290a82adb1b101682"
|
url: "https://pub.dev"
|
||||||
url: "https://github.com/mertalev/native"
|
source: hosted
|
||||||
source: git
|
|
||||||
version: "9.4.1"
|
version: "9.4.1"
|
||||||
octo_image:
|
octo_image:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
|
|||||||
+1
-6
@@ -84,7 +84,7 @@ dependencies:
|
|||||||
cupertino_http:
|
cupertino_http:
|
||||||
git:
|
git:
|
||||||
url: https://github.com/mertalev/http
|
url: https://github.com/mertalev/http
|
||||||
ref: '58b03c756b81d16b3975a8ae4b91d25360123bb5'
|
ref: 'a0a933358517c6d01cff37fc2a2752ee2d744a3c' # https://github.com/dart-lang/http/pull/1876
|
||||||
path: pkgs/cupertino_http/
|
path: pkgs/cupertino_http/
|
||||||
ok_http:
|
ok_http:
|
||||||
git:
|
git:
|
||||||
@@ -114,11 +114,6 @@ dev_dependencies:
|
|||||||
# Pin bonsoir to 5.x until cast releases a version compatible with bonsoir 6.x.
|
# Pin bonsoir to 5.x until cast releases a version compatible with bonsoir 6.x.
|
||||||
dependency_overrides:
|
dependency_overrides:
|
||||||
bonsoir: ^5.1.11
|
bonsoir: ^5.1.11
|
||||||
objective_c:
|
|
||||||
git:
|
|
||||||
url: https://github.com/mertalev/native
|
|
||||||
ref: '2915556701f4734a784222f290a82adb1b101682' # https://github.com/dart-lang/native/pull/3458
|
|
||||||
path: pkgs/objective_c/
|
|
||||||
|
|
||||||
flutter:
|
flutter:
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
|
|
||||||
import '../../unit/factories/local_asset_factory.dart';
|
|
||||||
import '../../unit/factories/remote_asset_factory.dart';
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
group('BaseAsset.refersToSameAsset', () {
|
|
||||||
test('search/folder copy (localId null) matches the merged DB copy (localId set)', () {
|
|
||||||
// #29472: search and folder assets arrive with localId null, then the viewer
|
|
||||||
// watches the DB copy which fills localId. heroTag embeds localId, so it
|
|
||||||
// diverges for the same asset and isCurrent used to go false.
|
|
||||||
final searchCopy = RemoteAssetFactory.create(id: 'asset-1');
|
|
||||||
final mergedCopy = searchCopy.copyWith(localId: 'local-1');
|
|
||||||
|
|
||||||
expect(searchCopy.localId, isNull);
|
|
||||||
expect(mergedCopy.localId, 'local-1');
|
|
||||||
expect(searchCopy.heroTag, isNot(mergedCopy.heroTag));
|
|
||||||
|
|
||||||
expect(searchCopy.refersToSameAsset(mergedCopy), isTrue);
|
|
||||||
expect(mergedCopy.refersToSameAsset(searchCopy), isTrue);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('different remote assets are not the same', () {
|
|
||||||
final a = RemoteAssetFactory.create(id: 'asset-1');
|
|
||||||
final b = RemoteAssetFactory.create(id: 'asset-2');
|
|
||||||
|
|
||||||
expect(a.refersToSameAsset(b), isFalse);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('same checksum but different remote ids are not the same (duplicate files)', () {
|
|
||||||
final a = RemoteAssetFactory.create(id: 'asset-1');
|
|
||||||
final b = RemoteAssetFactory.create(id: 'asset-2').copyWith(checksum: a.checksum);
|
|
||||||
|
|
||||||
expect(a.checksum, b.checksum);
|
|
||||||
expect(a.refersToSameAsset(b), isFalse);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('falls back to checksum when only one side has an id (local-only vs remote-only)', () {
|
|
||||||
final remoteOnly = RemoteAssetFactory.create(id: 'asset-1');
|
|
||||||
final localOnly = LocalAssetFactory.create(id: 'local-1').copyWith(checksum: remoteOnly.checksum);
|
|
||||||
|
|
||||||
expect(remoteOnly.refersToSameAsset(localOnly), isTrue);
|
|
||||||
expect(localOnly.refersToSameAsset(remoteOnly), isTrue);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:immich_mobile/domain/models/config/app_config.dart';
|
|
||||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
|
||||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
|
|
||||||
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
|
||||||
|
|
||||||
// A first fetch that never delivers - the state a suspended or storm-starved
|
|
||||||
// bucket watch is stuck in when the timeline mounts on a zero-sized first frame
|
|
||||||
class _FrozenBucketService implements TimelineService {
|
|
||||||
final _ctrl = StreamController<List<Bucket>>.broadcast();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Stream<List<Bucket>> Function() get watchBuckets =>
|
|
||||||
() => _ctrl.stream;
|
|
||||||
|
|
||||||
@override
|
|
||||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
|
||||||
}
|
|
||||||
|
|
||||||
class _EmptyBucketService implements TimelineService {
|
|
||||||
@override
|
|
||||||
Stream<List<Bucket>> Function() get watchBuckets =>
|
|
||||||
() => Stream.value(const []);
|
|
||||||
|
|
||||||
@override
|
|
||||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Counts how many times the bucket stream is subscribed. Each subscription is a
|
|
||||||
// fresh photo query, so the count is our proxy for "did the relayout re-run the
|
|
||||||
// segment query for an input it does not use".
|
|
||||||
class _CountingBucketService implements TimelineService {
|
|
||||||
int watchCount = 0;
|
|
||||||
final _ctrl = StreamController<List<Bucket>>.broadcast();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Stream<List<Bucket>> Function() get watchBuckets => () {
|
|
||||||
watchCount++;
|
|
||||||
return _ctrl.stream;
|
|
||||||
};
|
|
||||||
|
|
||||||
@override
|
|
||||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
|
||||||
}
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
testWidgets('timeline args follow constraints after a zero-sized first frame while buckets are still loading', (
|
|
||||||
tester,
|
|
||||||
) async {
|
|
||||||
tester.view.physicalSize = Size.zero;
|
|
||||||
tester.view.devicePixelRatio = 3.0;
|
|
||||||
addTearDown(tester.view.reset);
|
|
||||||
|
|
||||||
TimelineArgs? probed;
|
|
||||||
final probe = Consumer(
|
|
||||||
builder: (_, ref, __) {
|
|
||||||
probed = ref.watch(timelineArgsProvider);
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
await tester.pumpWidget(
|
|
||||||
ProviderScope(
|
|
||||||
overrides: [
|
|
||||||
timelineServiceProvider.overrideWithValue(_FrozenBucketService()),
|
|
||||||
appConfigProvider.overrideWithValue(const AppConfig()),
|
|
||||||
],
|
|
||||||
child: MaterialApp(home: Timeline(withScrubber: false, readOnly: true, loadingWidget: probe)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pump();
|
|
||||||
|
|
||||||
expect(probed, isNotNull);
|
|
||||||
expect(probed!.maxWidth, 0.0);
|
|
||||||
|
|
||||||
tester.view.physicalSize = const Size(1206, 2622);
|
|
||||||
await tester.pump();
|
|
||||||
await tester.pump();
|
|
||||||
|
|
||||||
expect(
|
|
||||||
probed!.maxWidth,
|
|
||||||
402.0,
|
|
||||||
reason: 'args locked to the zero-sized first frame leave the timeline blank for the whole session',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('timeline args follow constraints after a zero-sized first frame once buckets resolve', (tester) async {
|
|
||||||
tester.view.physicalSize = Size.zero;
|
|
||||||
tester.view.devicePixelRatio = 3.0;
|
|
||||||
addTearDown(tester.view.reset);
|
|
||||||
|
|
||||||
TimelineArgs? probed;
|
|
||||||
final probe = SliverToBoxAdapter(
|
|
||||||
child: Consumer(
|
|
||||||
builder: (_, ref, __) {
|
|
||||||
probed = ref.watch(timelineArgsProvider);
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
await tester.pumpWidget(
|
|
||||||
ProviderScope(
|
|
||||||
overrides: [
|
|
||||||
timelineServiceProvider.overrideWithValue(_EmptyBucketService()),
|
|
||||||
appConfigProvider.overrideWithValue(const AppConfig()),
|
|
||||||
],
|
|
||||||
child: MaterialApp(
|
|
||||||
home: Timeline(
|
|
||||||
withScrubber: false,
|
|
||||||
readOnly: true,
|
|
||||||
appBar: const SliverToBoxAdapter(child: SizedBox.shrink()),
|
|
||||||
topSliverWidget: probe,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pump();
|
|
||||||
await tester.pump();
|
|
||||||
|
|
||||||
tester.view.physicalSize = const Size(1206, 2622);
|
|
||||||
await tester.pump();
|
|
||||||
await tester.pump();
|
|
||||||
await tester.pump();
|
|
||||||
|
|
||||||
expect(probed, isNotNull);
|
|
||||||
expect(probed!.maxWidth, 402.0);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('a height-only relayout (multiselect app bar toggle) keeps the timeline, a width change refreshes it', (
|
|
||||||
tester,
|
|
||||||
) async {
|
|
||||||
final service = _CountingBucketService();
|
|
||||||
tester.view.devicePixelRatio = 3.0;
|
|
||||||
tester.view.physicalSize = const Size(1206, 2622);
|
|
||||||
addTearDown(tester.view.reset);
|
|
||||||
|
|
||||||
TimelineArgs? probed;
|
|
||||||
final probe = Consumer(
|
|
||||||
builder: (_, ref, __) {
|
|
||||||
probed = ref.watch(timelineArgsProvider);
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
await tester.pumpWidget(
|
|
||||||
ProviderScope(
|
|
||||||
overrides: [
|
|
||||||
timelineServiceProvider.overrideWithValue(service),
|
|
||||||
appConfigProvider.overrideWithValue(const AppConfig()),
|
|
||||||
],
|
|
||||||
child: MaterialApp(home: Timeline(withScrubber: false, readOnly: true, loadingWidget: probe)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pump();
|
|
||||||
|
|
||||||
final initialSubscriptions = service.watchCount;
|
|
||||||
final initialWidth = probed!.maxWidth;
|
|
||||||
final initialHeight = probed!.maxHeight;
|
|
||||||
expect(initialSubscriptions, greaterThan(0));
|
|
||||||
|
|
||||||
// toggling multiselect changes the app bar, so only the available height moves
|
|
||||||
tester.view.physicalSize = const Size(1206, 2000);
|
|
||||||
await tester.pump();
|
|
||||||
await tester.pump();
|
|
||||||
|
|
||||||
expect(probed!.maxHeight, isNot(initialHeight), reason: 'the height should have actually changed');
|
|
||||||
expect(probed!.maxWidth, initialWidth);
|
|
||||||
expect(
|
|
||||||
service.watchCount,
|
|
||||||
initialSubscriptions,
|
|
||||||
reason: 'a height-only change must not re-run the bucket query for an input the segments do not use',
|
|
||||||
);
|
|
||||||
|
|
||||||
// a real width change (rotation, fold, split screen) should refresh the tiles
|
|
||||||
tester.view.physicalSize = const Size(1000, 2000);
|
|
||||||
await tester.pump();
|
|
||||||
await tester.pump();
|
|
||||||
|
|
||||||
expect(probed!.maxWidth, lessThan(initialWidth));
|
|
||||||
expect(service.watchCount, greaterThan(initialSubscriptions));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -10552,24 +10552,7 @@
|
|||||||
"post": {
|
"post": {
|
||||||
"description": "Search for assets based on various metadata criteria.",
|
"description": "Search for assets based on various metadata criteria.",
|
||||||
"operationId": "searchAssets",
|
"operationId": "searchAssets",
|
||||||
"parameters": [
|
"parameters": [],
|
||||||
{
|
|
||||||
"name": "key",
|
|
||||||
"required": false,
|
|
||||||
"in": "query",
|
|
||||||
"schema": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "slug",
|
|
||||||
"required": false,
|
|
||||||
"in": "query",
|
|
||||||
"schema": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
|
|||||||
@@ -183,98 +183,6 @@
|
|||||||
},
|
},
|
||||||
"uiHints": ["Filter"]
|
"uiHints": ["Filter"]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "assetDateFilter",
|
|
||||||
"title": "Filter by date",
|
|
||||||
"description": "Filter assets by date taken",
|
|
||||||
"types": ["AssetV1"],
|
|
||||||
"schema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"startDate": {
|
|
||||||
"type": "object",
|
|
||||||
"title": "Start date",
|
|
||||||
"description": "Earliest date of assets to include",
|
|
||||||
"properties": {
|
|
||||||
"day": {
|
|
||||||
"type": "number",
|
|
||||||
"title": "Day",
|
|
||||||
"description": "Day of the year to match",
|
|
||||||
"uiHint": {
|
|
||||||
"order": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"month": {
|
|
||||||
"type": "number",
|
|
||||||
"title": "Month",
|
|
||||||
"description": "Month of the year to match",
|
|
||||||
"uiHint": {
|
|
||||||
"order": 2
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"year": {
|
|
||||||
"type": "number",
|
|
||||||
"title": "Year",
|
|
||||||
"description": "Year to match",
|
|
||||||
"uiHint": {
|
|
||||||
"order": 3
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"uiHint": {
|
|
||||||
"order": 1
|
|
||||||
},
|
|
||||||
"required": ["day", "month", "year"]
|
|
||||||
},
|
|
||||||
"endDate": {
|
|
||||||
"type": "object",
|
|
||||||
"title": "End date",
|
|
||||||
"description": "Latest date of assets to include",
|
|
||||||
"properties": {
|
|
||||||
"day": {
|
|
||||||
"type": "number",
|
|
||||||
"title": "Day",
|
|
||||||
"description": "Day of the year to match",
|
|
||||||
"uiHint": {
|
|
||||||
"order": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"month": {
|
|
||||||
"type": "number",
|
|
||||||
"title": "Month",
|
|
||||||
"description": "Month of the year to match",
|
|
||||||
"uiHint": {
|
|
||||||
"order": 2
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"year": {
|
|
||||||
"type": "number",
|
|
||||||
"title": "Year",
|
|
||||||
"description": "Year to match",
|
|
||||||
"uiHint": {
|
|
||||||
"order": 3
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"uiHint": {
|
|
||||||
"order": 2
|
|
||||||
},
|
|
||||||
"required": ["day", "month", "year"]
|
|
||||||
},
|
|
||||||
"recurring": {
|
|
||||||
"type": "boolean",
|
|
||||||
"default": false,
|
|
||||||
"title": "Match recurring dates",
|
|
||||||
"description": "Allow any assets with matching months/days regardless of the year",
|
|
||||||
"uiHint": {
|
|
||||||
"order": 3
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["recurring", "startDate", "endDate"]
|
|
||||||
},
|
|
||||||
"uiHints": ["Filter"]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "assetTypeFilter",
|
"name": "assetTypeFilter",
|
||||||
"title": "Filter by asset type",
|
"title": "Filter by asset type",
|
||||||
|
|||||||
@@ -124,27 +124,6 @@ const methods = wrapper<Manifest>({
|
|||||||
return { workflow: { continue: earthDiameter * delta <= (config.coordinate?.radius ?? 0) } };
|
return { workflow: { continue: earthDiameter * delta <= (config.coordinate?.radius ?? 0) } };
|
||||||
},
|
},
|
||||||
|
|
||||||
assetDateFilter: ({ config, data }) => {
|
|
||||||
const assetDate = new Date(data.asset.localDateTime);
|
|
||||||
let startDate = new Date(config.startDate.year, config.startDate.month - 1, config.startDate.day);
|
|
||||||
let endDate = new Date(config.endDate.year, config.endDate.month - 1, config.endDate.day);
|
|
||||||
|
|
||||||
if (config.recurring) {
|
|
||||||
startDate.setFullYear(assetDate.getFullYear());
|
|
||||||
endDate.setFullYear(assetDate.getFullYear());
|
|
||||||
|
|
||||||
if (endDate < startDate) {
|
|
||||||
if (assetDate > endDate) {
|
|
||||||
endDate.setFullYear(endDate.getFullYear() + 1);
|
|
||||||
} else {
|
|
||||||
startDate.setFullYear(startDate.getFullYear() - 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { workflow: { continue: assetDate >= startDate && assetDate <= endDate } };
|
|
||||||
},
|
|
||||||
|
|
||||||
assetLock: ({ config, data }) => {
|
assetLock: ({ config, data }) => {
|
||||||
if (!config.inverse && data.asset.visibility !== AssetVisibility.Locked) {
|
if (!config.inverse && data.asset.visibility !== AssetVisibility.Locked) {
|
||||||
return { changes: { asset: { visibility: AssetVisibility.Locked } } };
|
return { changes: { asset: { visibility: AssetVisibility.Locked } } };
|
||||||
@@ -200,7 +179,6 @@ const {
|
|||||||
assetFavorite,
|
assetFavorite,
|
||||||
assetFileFilter,
|
assetFileFilter,
|
||||||
assetLocationFilter,
|
assetLocationFilter,
|
||||||
assetDateFilter,
|
|
||||||
assetLock,
|
assetLock,
|
||||||
assetMissingTimeZoneFilter,
|
assetMissingTimeZoneFilter,
|
||||||
assetTypeFilter,
|
assetTypeFilter,
|
||||||
@@ -217,7 +195,6 @@ export {
|
|||||||
assetFavorite,
|
assetFavorite,
|
||||||
assetFileFilter,
|
assetFileFilter,
|
||||||
assetLocationFilter,
|
assetLocationFilter,
|
||||||
assetDateFilter,
|
|
||||||
assetLock,
|
assetLock,
|
||||||
assetMissingTimeZoneFilter,
|
assetMissingTimeZoneFilter,
|
||||||
assetTypeFilter,
|
assetTypeFilter,
|
||||||
|
|||||||
@@ -5764,18 +5764,13 @@ export function searchLargeAssets({ albumIds, city, country, createdAfter, creat
|
|||||||
/**
|
/**
|
||||||
* Search assets by metadata
|
* Search assets by metadata
|
||||||
*/
|
*/
|
||||||
export function searchAssets({ key, slug, metadataSearchDto }: {
|
export function searchAssets({ metadataSearchDto }: {
|
||||||
key?: string;
|
|
||||||
slug?: string;
|
|
||||||
metadataSearchDto: MetadataSearchDto;
|
metadataSearchDto: MetadataSearchDto;
|
||||||
}, opts?: Oazapfts.RequestOpts) {
|
}, opts?: Oazapfts.RequestOpts) {
|
||||||
return oazapfts.ok(oazapfts.fetchJson<{
|
return oazapfts.ok(oazapfts.fetchJson<{
|
||||||
status: 200;
|
status: 200;
|
||||||
data: SearchResponseDto;
|
data: SearchResponseDto;
|
||||||
}>(`/search/metadata${QS.query(QS.explode({
|
}>("/search/metadata", oazapfts.json({
|
||||||
key,
|
|
||||||
slug
|
|
||||||
}))}`, oazapfts.json({
|
|
||||||
...opts,
|
...opts,
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: metadataSearchDto
|
body: metadataSearchDto
|
||||||
|
|||||||
+1
-1
@@ -56,7 +56,7 @@ FROM builder AS plugins
|
|||||||
|
|
||||||
ARG TARGETPLATFORM
|
ARG TARGETPLATFORM
|
||||||
|
|
||||||
COPY --from=ghcr.io/jdx/mise:2026.7.2@sha256:51d92371cc08e3c4f0e52f58bb3ec1975a6dc966b7430a4da37dcfc6ae772f30 /usr/local/bin/mise /usr/local/bin/mise
|
COPY --from=ghcr.io/jdx/mise:2026.6.14@sha256:b8f8c20fc3308f8b1d00ccca2bc968e4e208af1c5c1069e1ad9753baa099acff /usr/local/bin/mise /usr/local/bin/mise
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY ./mise.toml ./mise.toml
|
COPY ./mise.toml ./mise.toml
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
FROM ghcr.io/immich-app/base-server-dev:202607061334@sha256:c83ef9c6d7f5e7f6a276fe96647484d46b7bf6cacc1e35b823dd1d7a1aeda3b8 AS dev
|
FROM ghcr.io/immich-app/base-server-dev:202607061334@sha256:c83ef9c6d7f5e7f6a276fe96647484d46b7bf6cacc1e35b823dd1d7a1aeda3b8 AS dev
|
||||||
|
|
||||||
|
|
||||||
COPY --from=ghcr.io/jdx/mise:2026.7.2@sha256:51d92371cc08e3c4f0e52f58bb3ec1975a6dc966b7430a4da37dcfc6ae772f30 /usr/local/bin/mise /usr/local/bin/mise
|
COPY --from=ghcr.io/jdx/mise:2026.6.14@sha256:b8f8c20fc3308f8b1d00ccca2bc968e4e208af1c5c1069e1ad9753baa099acff /usr/local/bin/mise /usr/local/bin/mise
|
||||||
|
|
||||||
RUN echo "devdir=/buildcache/node-gyp" >> /usr/local/etc/npmrc && \
|
RUN echo "devdir=/buildcache/node-gyp" >> /usr/local/etc/npmrc && \
|
||||||
echo "store-dir=/buildcache/pnpm-store" >> /usr/local/etc/npmrc && \
|
echo "store-dir=/buildcache/pnpm-store" >> /usr/local/etc/npmrc && \
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export class SearchController {
|
|||||||
constructor(private service: SearchService) {}
|
constructor(private service: SearchService) {}
|
||||||
|
|
||||||
@Post('metadata')
|
@Post('metadata')
|
||||||
@Authenticated({ permission: Permission.AssetRead, sharedLink: true })
|
@Authenticated({ permission: Permission.AssetRead })
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Endpoint({
|
@Endpoint({
|
||||||
summary: 'Search assets by metadata',
|
summary: 'Search assets by metadata',
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ select
|
|||||||
from
|
from
|
||||||
"asset_file"
|
"asset_file"
|
||||||
|
|
||||||
-- IntegrityRepository.streamAssetPathsForMissingFiles
|
-- IntegrityRepository.streamAssetPaths
|
||||||
select
|
select
|
||||||
"allPaths"."path" as "path",
|
"allPaths"."path" as "path",
|
||||||
"allPaths"."assetId",
|
"allPaths"."assetId",
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ export class IntegrityRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [], stream: true })
|
@GenerateSql({ params: [], stream: true })
|
||||||
streamAssetPathsForMissingFiles() {
|
streamAssetPaths() {
|
||||||
return this.db
|
return this.db
|
||||||
.selectFrom((eb) =>
|
.selectFrom((eb) =>
|
||||||
eb
|
eb
|
||||||
@@ -143,7 +143,7 @@ export class IntegrityRepository {
|
|||||||
)
|
)
|
||||||
.leftJoin('integrity_report', (join) =>
|
.leftJoin('integrity_report', (join) =>
|
||||||
join
|
join
|
||||||
.on('integrity_report.type', '=', IntegrityReport.MissingFile)
|
.on('integrity_report.type', '=', IntegrityReport.UntrackedFile)
|
||||||
.on((eb) =>
|
.on((eb) =>
|
||||||
eb.or([
|
eb.or([
|
||||||
eb('integrity_report.assetId', '=', eb.ref('allPaths.assetId')),
|
eb('integrity_report.assetId', '=', eb.ref('allPaths.assetId')),
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import { ExifDateTime, exiftool, WriteTags } from 'exiftool-vendored';
|
|||||||
import ffmpeg, { FfprobeData, FfprobeStream } from 'fluent-ffmpeg';
|
import ffmpeg, { FfprobeData, FfprobeStream } from 'fluent-ffmpeg';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import { Duration } from 'luxon';
|
import { Duration } from 'luxon';
|
||||||
import { spawn } from 'node:child_process';
|
import { execFile as execFileCb } from 'node:child_process';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import { Writable } from 'node:stream';
|
import { Writable } from 'node:stream';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
import { ORIENTATION_TO_SHARP_ROTATION } from 'src/constants';
|
import { ORIENTATION_TO_SHARP_ROTATION } from 'src/constants';
|
||||||
import { Exif } from 'src/database';
|
import { Exif } from 'src/database';
|
||||||
@@ -43,6 +44,8 @@ const probe = (input: string, options: string[]): Promise<FfprobeData> =>
|
|||||||
ffmpeg.ffprobe(input, options, (error, data) => (error ? reject(error) : resolve(data))),
|
ffmpeg.ffprobe(input, options, (error, data) => (error ? reject(error) : resolve(data))),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const execFile = promisify(execFileCb);
|
||||||
|
|
||||||
sharp.concurrency(0);
|
sharp.concurrency(0);
|
||||||
sharp.cache({ files: 0 });
|
sharp.cache({ files: 0 });
|
||||||
|
|
||||||
@@ -288,37 +291,33 @@ export class MediaRepository {
|
|||||||
* Needed for accurate segments, especially when remuxing, seeking and/or VFR is involved.
|
* Needed for accurate segments, especially when remuxing, seeking and/or VFR is involved.
|
||||||
* Scanning packets for keyframes in JS is much faster than -skip_frame nokey since it avoids decoding the video.
|
* Scanning packets for keyframes in JS is much faster than -skip_frame nokey since it avoids decoding the video.
|
||||||
*/
|
*/
|
||||||
probePackets(input: string, streamIndex: number): Promise<VideoPacketInfo | null> {
|
async probePackets(input: string, streamIndex: number): Promise<VideoPacketInfo | null> {
|
||||||
const ffprobe = spawn(
|
const { stdout } = await execFile('ffprobe', [
|
||||||
'ffprobe',
|
'-v',
|
||||||
[
|
'error',
|
||||||
'-v',
|
'-select_streams',
|
||||||
'error',
|
String(streamIndex),
|
||||||
'-select_streams',
|
'-show_entries',
|
||||||
String(streamIndex),
|
'packet=pts,duration,flags',
|
||||||
'-show_entries',
|
'-of',
|
||||||
'packet=pts,duration,flags',
|
'csv=p=0',
|
||||||
'-of',
|
input,
|
||||||
'csv=p=0',
|
]);
|
||||||
input,
|
|
||||||
],
|
|
||||||
{ stdio: ['ignore', 'pipe', 'pipe'] },
|
|
||||||
);
|
|
||||||
|
|
||||||
let totalDuration = 0;
|
let totalDuration = 0;
|
||||||
const keyframePts: number[] = [];
|
const keyframePts: number[] = [];
|
||||||
const keyframeAccDuration: number[] = [];
|
const keyframeAccDuration: number[] = [];
|
||||||
const keyframeOwnDuration: number[] = [];
|
const keyframeOwnDuration: number[] = [];
|
||||||
const postDiscard: { pts: number; duration: number }[] = [];
|
const postDiscard: { pts: number; duration: number }[] = [];
|
||||||
const parseLine = (line: string) => {
|
for (const line of stdout.split('\n')) {
|
||||||
if (!line) {
|
if (!line) {
|
||||||
return;
|
continue;
|
||||||
}
|
}
|
||||||
const [ptsStr, durationStr, flags] = line.split(',');
|
const [ptsStr, durationStr, flags] = line.split(',');
|
||||||
const pts = Number.parseInt(ptsStr);
|
const pts = Number.parseInt(ptsStr);
|
||||||
const duration = Number.parseInt(durationStr);
|
const duration = Number.parseInt(durationStr);
|
||||||
if (Number.isNaN(pts) || Number.isNaN(duration) || !flags) {
|
if (Number.isNaN(pts) || Number.isNaN(duration)) {
|
||||||
return;
|
continue;
|
||||||
}
|
}
|
||||||
// Discarded packets don't contribute to packet count, but still contribute to video duration
|
// Discarded packets don't contribute to packet count, but still contribute to video duration
|
||||||
totalDuration += duration;
|
totalDuration += duration;
|
||||||
@@ -333,43 +332,20 @@ export class MediaRepository {
|
|||||||
// Non-keyframes are accounted for in totalDuration.
|
// Non-keyframes are accounted for in totalDuration.
|
||||||
keyframeOwnDuration.push(duration);
|
keyframeOwnDuration.push(duration);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (postDiscard.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalDuration,
|
||||||
|
packetCount: postDiscard.length,
|
||||||
|
outputFrames: this.cfrOutputFrames(postDiscard, postDiscard.length / totalDuration),
|
||||||
|
keyframePts,
|
||||||
|
keyframeAccDuration,
|
||||||
|
keyframeOwnDuration,
|
||||||
};
|
};
|
||||||
|
|
||||||
let stderr = '';
|
|
||||||
let remainder = '';
|
|
||||||
ffprobe.stderr.setEncoding('utf8');
|
|
||||||
ffprobe.stderr.on('data', (chunk: string) => (stderr += chunk));
|
|
||||||
ffprobe.stdout.setEncoding('utf8');
|
|
||||||
ffprobe.stdout.on('data', (chunk: string) => {
|
|
||||||
const lines = chunk.split('\n');
|
|
||||||
lines[0] = remainder + lines[0];
|
|
||||||
remainder = lines.pop() as string;
|
|
||||||
for (const line of lines) {
|
|
||||||
parseLine(line);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Promise<VideoPacketInfo | null>((resolve, reject) => {
|
|
||||||
ffprobe.on('error', reject);
|
|
||||||
ffprobe.on('close', (code) => {
|
|
||||||
if (code !== 0) {
|
|
||||||
return reject(new Error(`ffprobe exited with code ${code}: ${stderr.trim()}`));
|
|
||||||
}
|
|
||||||
parseLine(remainder);
|
|
||||||
if (postDiscard.length === 0) {
|
|
||||||
return resolve(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve({
|
|
||||||
totalDuration,
|
|
||||||
packetCount: postDiscard.length,
|
|
||||||
outputFrames: this.cfrOutputFrames(postDiscard, postDiscard.length / totalDuration),
|
|
||||||
keyframePts,
|
|
||||||
keyframeAccDuration,
|
|
||||||
keyframeOwnDuration,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
transcode(input: string, output: string | Writable, options: TranscodeCommand): Promise<void> {
|
transcode(input: string, output: string | Writable, options: TranscodeCommand): Promise<void> {
|
||||||
|
|||||||
@@ -360,7 +360,7 @@ export class IntegrityService extends BaseService {
|
|||||||
|
|
||||||
this.logger.log(`Scanning for missing files...`);
|
this.logger.log(`Scanning for missing files...`);
|
||||||
|
|
||||||
const assetPaths = this.integrityRepository.streamAssetPathsForMissingFiles();
|
const assetPaths = this.integrityRepository.streamAssetPaths();
|
||||||
|
|
||||||
let total = 0;
|
let total = 0;
|
||||||
for await (const batchPaths of chunk(assetPaths, JOBS_LIBRARY_PAGINATION_SIZE)) {
|
for await (const batchPaths of chunk(assetPaths, JOBS_LIBRARY_PAGINATION_SIZE)) {
|
||||||
|
|||||||
@@ -77,8 +77,6 @@ export class SearchService extends BaseService {
|
|||||||
|
|
||||||
if (dto.albumIds && dto.albumIds.length > 0) {
|
if (dto.albumIds && dto.albumIds.length > 0) {
|
||||||
await this.requireAccess({ auth, ids: dto.albumIds, permission: Permission.AlbumRead });
|
await this.requireAccess({ auth, ids: dto.albumIds, permission: Permission.AlbumRead });
|
||||||
} else if (auth.sharedLink) {
|
|
||||||
throw new BadRequestException('Shared link access is only allowed in combination with an albumIds filter');
|
|
||||||
} else {
|
} else {
|
||||||
userIds = await this.getUserIdsToSearch(auth, dto.visibility);
|
userIds = await this.getUserIdsToSearch(auth, dto.visibility);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,10 +14,6 @@ describe('asDateString', () => {
|
|||||||
const date = new Date(2000, 0, 15); // 15 Jan 2000, local midnight
|
const date = new Date(2000, 0, 15); // 15 Jan 2000, local midnight
|
||||||
expect(asDateString(date)).toBe('2000-01-15');
|
expect(asDateString(date)).toBe('2000-01-15');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should correctly pad years with a leading 0', () => {
|
|
||||||
expect(asDateString(new Date('280-12-12'))).toBe('0280-12-12');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('asDateTimeString', () => {
|
describe('asDateTimeString', () => {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { ArgumentMetadata, FileValidator, Injectable, ParseUUIDPipe } from '@nestjs/common';
|
import { ArgumentMetadata, FileValidator, Injectable, ParseUUIDPipe } from '@nestjs/common';
|
||||||
import { DateTime } from 'luxon';
|
|
||||||
import { createZodDto } from 'nestjs-zod';
|
import { createZodDto } from 'nestjs-zod';
|
||||||
import sanitize from 'sanitize-filename';
|
import sanitize from 'sanitize-filename';
|
||||||
import { isIP, isIPRange } from 'validator';
|
import { isIP, isIPRange } from 'validator';
|
||||||
@@ -174,7 +173,12 @@ export const isoDateToDate = z
|
|||||||
z.date(),
|
z.date(),
|
||||||
{
|
{
|
||||||
decode: (isoString) => new Date(isoString),
|
decode: (isoString) => new Date(isoString),
|
||||||
encode: (date) => DateTime.fromJSDate(date).toFormat('yyyy-MM-dd'),
|
encode: (date) => {
|
||||||
|
const y = date.getFullYear();
|
||||||
|
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const d = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${d}`;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.meta({ example: '2024-01-01' });
|
.meta({ example: '2024-01-01' });
|
||||||
|
|||||||
@@ -408,7 +408,7 @@ describe(IntegrityService.name, () => {
|
|||||||
} = await ctx.newAsset({ ownerId, originalPath: '/path/to/file2' });
|
} = await ctx.newAsset({ ownerId, originalPath: '/path/to/file2' });
|
||||||
|
|
||||||
const { id: reportId } = await ctx.get(IntegrityRepository).create({
|
const { id: reportId } = await ctx.get(IntegrityRepository).create({
|
||||||
type: IntegrityReport.MissingFile,
|
type: IntegrityReport.UntrackedFile,
|
||||||
path: '/path/to/file2',
|
path: '/path/to/file2',
|
||||||
assetId: assetId2,
|
assetId: assetId2,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -242,7 +242,6 @@
|
|||||||
|
|
||||||
function removeFilter(key: keyof SearchTerms) {
|
function removeFilter(key: keyof SearchTerms) {
|
||||||
delete terms[key];
|
delete terms[key];
|
||||||
assetMultiSelectManager.clear();
|
|
||||||
void goto(Route.search(terms));
|
void goto(Route.search(terms));
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user