Compare commits

..

17 Commits

Author SHA1 Message Date
renovate[bot] 0fceda1ced fix(deps): update typescript-projects 2026-07-09 10:43:12 +00:00
Mees Frensel 4fd58885e2 fix(web): recursively unnest class arrays (#29755) 2026-07-09 12:39:12 +02:00
Ben Beckford c5222c7579 chore(server): use bounded numbers in workflows (#29009)
* chore(server): use bounded numbers in workflows

* chore: increase precision for lat/lon filtering

* chore: re-add integer to a json schema type
2026-07-08 17:15:48 -07:00
Santo Shakil c4f084f4a5 fix(mobile): play videos from search results and folder view (#29693)
* fix(mobile): play videos from search results and folder view

* pr suggestion

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-07-08 20:22:35 +00:00
Daniel Dietzler 66e30d5fa1 fix: missing files integrity checks (#29737) 2026-07-08 14:53:46 -05:00
Daniel Dietzler 053a52aec8 fix: iso date parsing for three digit years (#29739) 2026-07-08 14:53:16 -05:00
Santo Shakil 2ad554fc61 fix(mobile): update timeline args on layout constraint changes (#29653)
* fix(mobile): update timeline args on layout constraint changes

* chore(mobile): temporary blank timeline diagnostics

* chore(mobile): error logs when the timeline goes blank

* chore(mobile): remove blank timeline debug logging

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-07-08 14:13:32 -05:00
Kha Nguyen 427321a102 fix(web): clear asset selection when search context changes (#29738) 2026-07-08 14:08:39 -05:00
Daniel Dietzler 655cd01d57 fix: search metadata for album shared links (#29742) 2026-07-08 20:32:20 +02:00
Mert cfd95f0196 fix(server): stream ffprobe packets (#29723) 2026-07-08 12:51:22 -04:00
renovate[bot] 439ace9dba chore(deps): update ghcr.io/jdx/mise docker tag to v2026.7.2 (#29678) 2026-07-08 13:22:06 +02:00
Ben Beckford 5c2fb2245a feat(server): date workflow filter (#29040) 2026-07-08 12:05:44 +02:00
shenlong 924e6251fd fix: do not log out on automatic url switch (#29715)
fix: sync headers to external endpoints

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-07-07 21:21:37 -05:00
Mert 3aebb82905 fix(mobile): http request handling after isolate death (#29714)
* use ports for objc

* add task reaper

* cleanup, fewer allocations

* link to pr
2026-07-07 16:31:10 -04:00
Daniel Dietzler 7a9b9e01be feat: user agnostic database restore (#29512) 2026-07-07 21:16:48 +02:00
Daniel Dietzler 0cbe07c4d4 fix: integrity checks dto validation (#29621) 2026-07-07 20:49:26 +02:00
renovate[bot] 45ea248e8b chore(deps): update github-actions (#29677) 2026-07-07 19:56:57 +02:00
55 changed files with 737 additions and 234 deletions
+4 -4
View File
@@ -78,10 +78,10 @@ jobs:
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Login to GitHub Container Registry
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
if: ${{ !github.event.pull_request.head.repo.fork }}
with:
registry: ghcr.io
@@ -96,7 +96,7 @@ jobs:
- name: Generate docker image tags
id: metadata
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
flavor: |
latest=false
@@ -107,7 +107,7 @@ jobs:
type=raw,value=latest,enable=${{ github.event_name == 'release' && !github.event.release.prerelease }}
- name: Build and push image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
file: packages/cli/Dockerfile
platforms: linux/amd64,linux/arm64
+3 -3
View File
@@ -57,7 +57,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
uses: github/codeql-action/init@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -70,7 +70,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
uses: github/codeql-action/autobuild@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
# ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -83,6 +83,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
with:
category: '/language:${{matrix.language}}'
+2 -2
View File
@@ -60,7 +60,7 @@ jobs:
suffix: ['', '-cuda', '-rocm', '-openvino', '-armnn', '-rknn']
steps:
- name: Login to GitHub Container Registry
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -90,7 +90,7 @@ jobs:
suffix: ['']
steps:
- name: Login to GitHub Container Registry
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
+1 -1
View File
@@ -28,4 +28,4 @@ run = "prettier --write ."
run = "wrangler pages deploy build --project-name=${PROJECT_NAME} --branch=${BRANCH_NAME}"
[tools]
wrangler = "4.100.0"
wrangler = "4.107.0"
@@ -4,6 +4,7 @@ import {
AssetVisibility,
deleteAssets,
LoginResponseDto,
SharedLinkType,
updateAsset,
} from '@immich/sdk';
import { DateTime } from 'luxon';
@@ -357,6 +358,32 @@ describe('/search', () => {
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', () => {
+19 -19
View File
@@ -287,43 +287,43 @@ url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536406"
[[tools.pnpm]]
version = "11.6.0"
version = "11.9.0"
backend = "aqua:pnpm/pnpm"
[tools.pnpm."platforms.linux-arm64"]
checksum = "sha256:2fec653ff6dadab340d1c3d2214688a7451cc471f39710839440b293ca7c53b0"
url = "https://github.com/pnpm/pnpm/releases/download/v11.6.0/pnpm-linux-arm64.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/445120174"
checksum = "sha256:7981d0109fffdf8f15b838243240c8c9f9114dd4b7111d4227c1453522d75b61"
url = "https://github.com/pnpm/pnpm/releases/download/v11.9.0/pnpm-linux-arm64.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/455748044"
provenance = "github-attestations"
[tools.pnpm."platforms.linux-arm64-musl"]
checksum = "sha256:56a78c08cf22adf29e7dacb6f7f100139731693863d20fb94a7883463a62169e"
url = "https://github.com/pnpm/pnpm/releases/download/v11.6.0/pnpm-linux-arm64-musl.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/445120177"
checksum = "sha256:3622d3010d6af601190e7b63994fb0fe297e805699145aec949f4879f2672fb5"
url = "https://github.com/pnpm/pnpm/releases/download/v11.9.0/pnpm-linux-arm64-musl.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/455748040"
provenance = "github-attestations"
[tools.pnpm."platforms.linux-x64"]
checksum = "sha256:74d64c1646385fb21691f32f0ab6aca1a9f5c829ba54d3cda3a24838a228e68c"
url = "https://github.com/pnpm/pnpm/releases/download/v11.6.0/pnpm-linux-x64.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/445120176"
checksum = "sha256:8d987d82585453bcf260ea5d0bae346d298f1a5e71bcde8d99976521408ad895"
url = "https://github.com/pnpm/pnpm/releases/download/v11.9.0/pnpm-linux-x64.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/455748039"
provenance = "github-attestations"
[tools.pnpm."platforms.linux-x64-musl"]
checksum = "sha256:7a0c463a09d912fba6b7d9eca0a946bc228ea50f3015a05c09a29e7e85a932d7"
url = "https://github.com/pnpm/pnpm/releases/download/v11.6.0/pnpm-linux-x64-musl.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/445120175"
checksum = "sha256:655fcf2609e3145cfd7c2f05113429523706d0a618f2957eaa6c3562f6a5998b"
url = "https://github.com/pnpm/pnpm/releases/download/v11.9.0/pnpm-linux-x64-musl.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/455748037"
provenance = "github-attestations"
[tools.pnpm."platforms.macos-arm64"]
checksum = "sha256:87c901635a14481fb30566a3749041134ffd4317bc6fe866c345b69fdf9b6b85"
url = "https://github.com/pnpm/pnpm/releases/download/v11.6.0/pnpm-darwin-arm64.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/445120180"
checksum = "sha256:ca6f98a8911ec975509464026205e2b54aed74cecd36c5ae9932e5997bd7de2a"
url = "https://github.com/pnpm/pnpm/releases/download/v11.9.0/pnpm-darwin-arm64.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/455748038"
provenance = "github-attestations"
[tools.pnpm."platforms.windows-x64"]
checksum = "sha256:91c753435542b04859c689304fae0dd64eba6b40937cfa426a48485b712e4e9c"
url = "https://github.com/pnpm/pnpm/releases/download/v11.6.0/pnpm-win32-x64.zip"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/445120173"
checksum = "sha256:82c130717a59f237c49e8eaa8a7ed75c1d8d351a5b6dedeee3196b8e1b597c6e"
url = "https://github.com/pnpm/pnpm/releases/download/v11.9.0/pnpm-win32-x64.zip"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/455748043"
provenance = "github-attestations"
[[tools.terragrunt]]
+1 -1
View File
@@ -16,7 +16,7 @@ config_roots = [
[tools]
node = "24.15.0"
pnpm = "11.6.0"
pnpm = "11.9.0"
terragrunt = "1.0.3"
opentofu = "1.11.6"
"npm:oazapfts" = "7.5.0"
@@ -61,6 +61,17 @@ sealed class BaseAsset {
bool get isLocalOnly => storage == AssetState.local;
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;
// Overridden in subclasses
@@ -413,16 +413,7 @@ class LocalSyncService {
if (localAssetsToTrash.isNotEmpty) {
final localIds = localAssetsToTrash.values.expand((assets) => assets).map((asset) => asset.id).toList();
_log.info("Moving to trash ${localIds.join(", ")} assets");
final List<String> movedIds;
try {
movedIds = await _assetMediaRepository.deleteAll(localIds);
} catch (error, stackTrace) {
_log.severe("Failed to move local assets to trash", error, stackTrace);
// Automatic trash sync shouldn't block sync of other assets
// If the delete fails because an asset is in trash, the successive sync process
// will also remove it from the local DB
return;
}
final movedIds = await _assetMediaRepository.deleteAll(localIds);
if (movedIds.isNotEmpty) {
final movedAssetsByAlbum = localAssetsToTrash.map(
(albumId, assets) => MapEntry(albumId, assets.where((asset) => movedIds.contains(asset.id)).toList()),
@@ -516,14 +516,7 @@ class SyncStreamService {
Future<void> _trashLocalAssets(Map<String, List<LocalAsset>> localAssetsToTrash) async {
final localIds = localAssetsToTrash.values.expand((assets) => assets).map((asset) => asset.id).toList();
_logger.info("Moving to trash ${localIds.join(", ")} assets");
final List<String> movedIds;
try {
movedIds = await _assetMediaRepository.deleteAll(localIds);
} catch (error, stackTrace) {
_logger.severe("Failed to move local assets to trash", error, stackTrace);
// The local sync service retries this on the next sync, so it is safe to log and exit early
return;
}
final movedIds = await _assetMediaRepository.deleteAll(localIds);
if (movedIds.isNotEmpty) {
final movedAssetsByAlbum = localAssetsToTrash.map(
(albumId, assets) => MapEntry(albumId, assets.where((asset) => movedIds.contains(asset.id)).toList()),
@@ -395,7 +395,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
@override
Widget build(BuildContext context) {
final currentHeroTag = ref.watch(assetViewerProvider.select((s) => s.currentAsset?.heroTag));
final currentAsset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
_showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
final stackIndex = ref.watch(assetViewerProvider.select((s) => s.stackIndex));
final isPlayingMotionVideo = ref.watch(isPlayingMotionVideoProvider);
@@ -414,7 +414,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
displayAsset = stackChildren.elementAt(stackIndex);
}
final isCurrent = currentHeroTag == displayAsset.heroTag;
final isCurrent = currentAsset != null && currentAsset.refersToSameAsset(displayAsset);
final viewportWidth = MediaQuery.widthOf(context);
final viewportHeight = MediaQuery.heightOf(context);
@@ -86,13 +86,14 @@ class TimelineStateNotifier extends Notifier<TimelineState> {
// 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
final timelineSegmentProvider = StreamProvider.autoDispose<List<Segment>>((ref) async* {
final args = ref.watch(timelineArgsProvider);
final columnCount = args.columnCount;
final spacing = args.spacing;
final availableTileWidth = args.maxWidth - (spacing * (columnCount - 1));
// maxHeight is left out on purpose, a height-only change must not restart the bucket stream
final (maxWidth, columnCount, spacing, groupByArg) = ref.watch(
timelineArgsProvider.select((args) => (args.maxWidth, args.columnCount, args.spacing, args.groupBy)),
);
final availableTileWidth = maxWidth - (spacing * (columnCount - 1));
final tileExtent = math.max(0, availableTileWidth) / columnCount;
final groupBy = args.groupBy ?? ref.watch(appConfigProvider.select((config) => config.timeline.groupAssetsBy));
final groupBy = groupByArg ?? ref.watch(appConfigProvider.select((config) => config.timeline.groupAssetsBy));
final timelineService = ref.watch(timelineServiceProvider);
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/selection_sliver_app_bar.dart';
class Timeline extends StatelessWidget {
class Timeline extends ConsumerWidget {
const Timeline({
super.key,
this.topSliverWidget,
@@ -62,35 +62,40 @@ class Timeline extends StatelessWidget {
final Widget? loadingWidget;
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final columnCount = ref.watch(appConfigProvider.select((config) => config.timeline.tilesPerRow));
return LayoutBuilder(
builder: (_, constraints) => ProviderScope(
overrides: [
timelineArgsProvider.overrideWith(
(ref) => TimelineArgs(
maxWidth: constraints.maxWidth,
maxHeight: constraints.maxHeight,
columnCount: ref.watch(appConfigProvider.select((config) => config.timeline.tilesPerRow)),
showStorageIndicator: showStorageIndicator,
withStack: withStack,
groupBy: groupBy,
builder: (_, constraints) {
return ProviderScope(
overrides: [
// overrideWithValue keeps the scoped args in sync with the latest constraints on rebuilds,
// a function override would stay locked to the first frame's constraints for the whole session
timelineArgsProvider.overrideWithValue(
TimelineArgs(
maxWidth: constraints.maxWidth,
maxHeight: constraints.maxHeight,
columnCount: columnCount,
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,
),
),
);
},
);
}
}
@@ -169,13 +174,11 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBi
void didUpdateWidget(covariant _SliverTimeline oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.maxWidth != oldWidget.maxWidth) {
final asyncSegments = ref.read(timelineSegmentProvider);
asyncSegments.whenData((segments) {
final index = _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;
});
// The updated args already regenerate the segments, only remember the scroll position to restore it afterwards
final segments = ref.read(timelineSegmentProvider).valueOrNull;
if (segments != null && _scrollController.hasClients) {
_restoreAssetIndex = _getCurrentAssetIndex(segments);
}
}
}
+1
View File
@@ -183,6 +183,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
Future<void> saveLocalEndpoint(String url) async {
await _ref.read(settingsProvider).write(.networkLocalEndpoint, url);
await _apiService.updateHeaders();
}
String? getSavedWifiName() {
@@ -1,3 +1,4 @@
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
@@ -39,5 +40,10 @@ final timelineUsersProvider = StreamProvider<List<String>>((ref) {
return Stream.value([]);
}
return ref.watch(timelineRepositoryProvider).watchTimelineUserIds(currentUserId);
// Drift re-emits a fresh but content-identical list on unrelated table updates,
// which would dispose and rebuild the timeline service mid-load
return ref
.watch(timelineRepositoryProvider)
.watchTimelineUserIds(currentUserId)
.distinct(const ListEquality<String>().equals);
});
@@ -45,7 +45,6 @@ class AssetMediaRepository {
return false;
}
// TODO: Replace the calls with a native pigeon method that filters ids no longer in the MediaStore before building the request
Future<List<String>> deleteAll(List<String> ids) async {
if (CurrentPlatform.isAndroid) {
if (await _androidSupportsTrash()) {
@@ -5,6 +5,7 @@ import 'package:immich_mobile/domain/models/settings_key.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.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/widgets/settings/networking_settings/endpoint_input.dart';
@@ -26,13 +27,16 @@ class ExternalNetworkPreference extends HookConsumerWidget {
.map((e) => e.url)
.toList();
ref.read(settingsProvider).write(SettingsKey.networkExternalEndpointList, urls);
return ref.read(settingsProvider).write(SettingsKey.networkExternalEndpointList, urls);
}
updateValidationStatus(String url, int index, AuxCheckStatus status) {
updateValidationStatus(String url, int index, AuxCheckStatus status) async {
entries.value[index] = entries.value[index].copyWith(url: url, status: status);
saveEndpointList();
await saveEndpointList();
if (status == AuxCheckStatus.valid) {
await ref.read(apiServiceProvider).updateHeaders();
}
}
handleReorder(int oldIndex, int newIndex) {
+18 -3
View File
@@ -302,7 +302,11 @@ class SearchApi {
/// Parameters:
///
/// * [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
final apiPath = r'/search/metadata';
@@ -313,6 +317,13 @@ class SearchApi {
final headerParams = <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'];
@@ -335,8 +346,12 @@ class SearchApi {
/// Parameters:
///
/// * [MetadataSearchDto] metadataSearchDto (required):
Future<SearchResponseDto?> searchAssets(MetadataSearchDto metadataSearchDto, { Future<void>? abortTrigger, }) async {
final response = await searchAssetsWithHttpInfo(metadataSearchDto, abortTrigger: abortTrigger,);
///
/// * [String] key:
///
/// * [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) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
@@ -28,8 +28,8 @@ class SystemConfigIntegrityChecksumJob {
/// Percentage limit of the integrity checksum job
///
/// Minimum value: 0
/// Maximum value: 9007199254740991
int percentageLimit;
/// Maximum value: 1
double percentageLimit;
/// How long the integrity checksum job may run for
///
@@ -75,7 +75,7 @@ class SystemConfigIntegrityChecksumJob {
return SystemConfigIntegrityChecksumJob(
cronExpression: mapValueOfType<String>(json, r'cronExpression')!,
enabled: mapValueOfType<bool>(json, r'enabled')!,
percentageLimit: mapValueOfType<int>(json, r'percentageLimit')!,
percentageLimit: mapValueOfType<double>(json, r'percentageLimit')!,
timeLimit: mapValueOfType<int>(json, r'timeLimit')!,
);
}
+8 -7
View File
@@ -309,8 +309,8 @@ packages:
dependency: "direct main"
description:
path: "pkgs/cupertino_http"
ref: a0a933358517c6d01cff37fc2a2752ee2d744a3c
resolved-ref: a0a933358517c6d01cff37fc2a2752ee2d744a3c
ref: "58b03c756b81d16b3975a8ae4b91d25360123bb5"
resolved-ref: "58b03c756b81d16b3975a8ae4b91d25360123bb5"
url: "https://github.com/mertalev/http"
source: git
version: "3.0.0-wip"
@@ -1158,12 +1158,13 @@ packages:
source: hosted
version: "0.5.0"
objective_c:
dependency: transitive
dependency: "direct overridden"
description:
name: objective_c
sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed"
url: "https://pub.dev"
source: hosted
path: "pkgs/objective_c"
ref: "2915556701f4734a784222f290a82adb1b101682"
resolved-ref: "2915556701f4734a784222f290a82adb1b101682"
url: "https://github.com/mertalev/native"
source: git
version: "9.4.1"
octo_image:
dependency: "direct main"
+6 -1
View File
@@ -84,7 +84,7 @@ dependencies:
cupertino_http:
git:
url: https://github.com/mertalev/http
ref: 'a0a933358517c6d01cff37fc2a2752ee2d744a3c' # https://github.com/dart-lang/http/pull/1876
ref: '58b03c756b81d16b3975a8ae4b91d25360123bb5'
path: pkgs/cupertino_http/
ok_http:
git:
@@ -114,6 +114,11 @@ dev_dependencies:
# Pin bonsoir to 5.x until cast releases a version compatible with bonsoir 6.x.
dependency_overrides:
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:
uses-material-design: true
@@ -0,0 +1,46 @@
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,7 +1,6 @@
import 'package:drift/drift.dart' as drift;
import 'package:drift/native.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
@@ -198,24 +197,6 @@ void main() {
expect(trashArgs['album-a'], [movedAsset]);
});
test('continues the device diff when moving local assets to trash fails', () async {
await Store.put(StoreKey.manageLocalMediaAndroid, true);
when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => true);
when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer(
(_) async => {
'album-a': [LocalAssetStub.image1],
},
);
when(
() => mockAssetMediaRepository.deleteAll(any()),
).thenThrow(PlatformException(code: 'PLATFORM_ERROR', message: 'deleteWithIds failed'));
await sut.sync();
verifyNever(() => mockTrashedLocalAssetRepository.trashLocalAsset(any()));
verify(() => mockNativeSyncApi.getMediaChanges()).called(1);
});
test('does not attempt restore when repository has no assets to restore', () async {
when(() => mockTrashedLocalAssetRepository.getToRestore()).thenAnswer((_) async => []);
@@ -3,7 +3,6 @@ import 'dart:async';
import 'package:drift/drift.dart' as drift;
import 'package:drift/native.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
@@ -37,6 +36,7 @@ class _AbortCallbackWrapper {
class _MockAbortCallbackWrapper extends Mock implements _AbortCallbackWrapper {}
void main() {
late SyncStreamService sut;
late SyncStreamRepository mockSyncStreamRepo;
@@ -494,32 +494,6 @@ void main() {
expect(trashArgs['album-a'], [movedAsset]);
});
test("acks the batch and skips trash bookkeeping when moving assets to device trash fails", () async {
final localAsset = LocalAssetStub.image1.copyWith(id: 'stale-local', checksum: 'checksum-stale');
when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).thenAnswer(
(_) async => {
'album-a': [localAsset],
},
);
when(
() => mockAssetMediaRepo.deleteAll(any()),
).thenThrow(PlatformException(code: 'PLATFORM_ERROR', message: 'deleteWithIds failed'));
final events = [
SyncStreamStub.assetTrashed(
id: 'remote-1',
checksum: localAsset.checksum!,
ack: 'asset-remote-1',
trashedAt: DateTime(2025, 5, 1),
),
];
await simulateEvents(events);
verifyNever(() => mockTrashedLocalAssetRepo.trashLocalAsset(any()));
verify(() => mockSyncApiRepo.ack(['asset-remote-1'])).called(1);
});
test("skips device trashing when no local assets match the remote trash payload", () async {
final events = [
SyncStreamStub.assetTrashed(
@@ -0,0 +1,190 @@
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));
});
}
+21 -3
View File
@@ -10552,7 +10552,24 @@
"post": {
"description": "Search for assets based on various metadata criteria.",
"operationId": "searchAssets",
"parameters": [],
"parameters": [
{
"name": "key",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "slug",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
@@ -25917,9 +25934,10 @@
},
"percentageLimit": {
"description": "Percentage limit of the integrity checksum job",
"maximum": 9007199254740991,
"format": "double",
"maximum": 1,
"minimum": 0,
"type": "integer"
"type": "number"
},
"timeLimit": {
"description": "How long the integrity checksum job may run for",
+1 -1
View File
@@ -8,7 +8,7 @@
"format": "prettier --cache --check i18n/",
"format:fix": "prettier --cache --write --list-different i18n"
},
"packageManager": "pnpm@11.6.0",
"packageManager": "pnpm@11.9.0",
"engines": {
"pnpm": ">=10.0.0"
},
+1 -1
View File
@@ -13,5 +13,5 @@
"oidc-provider": "^9.0.0",
"tsx": "^4.20.6"
},
"packageManager": "pnpm@11.6.0"
"packageManager": "pnpm@11.9.0"
}
+104 -5
View File
@@ -163,19 +163,26 @@
"description": "Filter by distance to a coordinate",
"properties": {
"latitude": {
"type": "string",
"type": "number",
"title": "Latitude",
"description": "GPS latitude of a coordinate which the asset must be close to"
"description": "GPS latitude of a coordinate which the asset must be close to",
"minimum": -90,
"maximum": 90,
"precision": 0.000001
},
"longitude": {
"type": "string",
"type": "number",
"title": "Longitude",
"description": "GPS longitude of a coordinate which the asset must be close to"
"description": "GPS longitude of a coordinate which the asset must be close to",
"minimum": -180,
"maximum": 180,
"precision": 0.000001
},
"radius": {
"type": "number",
"title": "Maximum distance",
"description": "How close in kilometres the asset must be to the given point"
"description": "How close in kilometres the asset must be to the given point",
"minimum": 0
}
}
}
@@ -183,6 +190,98 @@
},
"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",
"title": "Filter by asset type",
+26 -3
View File
@@ -96,10 +96,10 @@ const methods = wrapper<Manifest>({
return { workflow: { continue: false } };
}
const configLat = Number.parseFloat(config.coordinate?.latitude ?? '');
const configLon = Number.parseFloat(config.coordinate?.longitude ?? '');
const configLat = config.coordinate?.latitude;
const configLon = config.coordinate?.longitude;
if (Number.isNaN(configLat) || Number.isNaN(configLat)) {
if (configLat === undefined || configLon === undefined) {
return { workflow: { continue: true } };
}
@@ -124,6 +124,27 @@ const methods = wrapper<Manifest>({
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 }) => {
if (!config.inverse && data.asset.visibility !== AssetVisibility.Locked) {
return { changes: { asset: { visibility: AssetVisibility.Locked } } };
@@ -179,6 +200,7 @@ const {
assetFavorite,
assetFileFilter,
assetLocationFilter,
assetDateFilter,
assetLock,
assetMissingTimeZoneFilter,
assetTypeFilter,
@@ -195,6 +217,7 @@ export {
assetFavorite,
assetFileFilter,
assetLocationFilter,
assetDateFilter,
assetLock,
assetMissingTimeZoneFilter,
assetTypeFilter,
+1 -1
View File
@@ -27,7 +27,7 @@
"keywords": [],
"author": "",
"license": "GNU Affero General Public License version 3",
"packageManager": "pnpm@11.6.0",
"packageManager": "pnpm@11.9.0",
"devDependencies": {
"@extism/js-pdk": "^1.1.1",
"@immich/sdk": "workspace:*",
+1 -1
View File
@@ -32,5 +32,5 @@
"vite": "^8.0.16",
"vitest": "^4.1.8"
},
"packageManager": "pnpm@11.6.0"
"packageManager": "pnpm@11.9.0"
}
+7 -2
View File
@@ -5764,13 +5764,18 @@ export function searchLargeAssets({ albumIds, city, country, createdAfter, creat
/**
* Search assets by metadata
*/
export function searchAssets({ metadataSearchDto }: {
export function searchAssets({ key, slug, metadataSearchDto }: {
key?: string;
slug?: string;
metadataSearchDto: MetadataSearchDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: SearchResponseDto;
}>("/search/metadata", oazapfts.json({
}>(`/search/metadata${QS.query(QS.explode({
key,
slug
}))}`, oazapfts.json({
...opts,
method: "POST",
body: metadataSearchDto
+1 -1
View File
@@ -56,7 +56,7 @@ FROM builder AS plugins
ARG TARGETPLATFORM
COPY --from=ghcr.io/jdx/mise:2026.6.14@sha256:b8f8c20fc3308f8b1d00ccca2bc968e4e208af1c5c1069e1ad9753baa099acff /usr/local/bin/mise /usr/local/bin/mise
COPY --from=ghcr.io/jdx/mise:2026.7.2@sha256:51d92371cc08e3c4f0e52f58bb3ec1975a6dc966b7430a4da37dcfc6ae772f30 /usr/local/bin/mise /usr/local/bin/mise
WORKDIR /app
COPY ./mise.toml ./mise.toml
+1 -1
View File
@@ -2,7 +2,7 @@
FROM ghcr.io/immich-app/base-server-dev:202607061334@sha256:c83ef9c6d7f5e7f6a276fe96647484d46b7bf6cacc1e35b823dd1d7a1aeda3b8 AS dev
COPY --from=ghcr.io/jdx/mise:2026.6.14@sha256:b8f8c20fc3308f8b1d00ccca2bc968e4e208af1c5c1069e1ad9753baa099acff /usr/local/bin/mise /usr/local/bin/mise
COPY --from=ghcr.io/jdx/mise:2026.7.2@sha256:51d92371cc08e3c4f0e52f58bb3ec1975a6dc966b7430a4da37dcfc6ae772f30 /usr/local/bin/mise /usr/local/bin/mise
RUN echo "devdir=/buildcache/node-gyp" >> /usr/local/etc/npmrc && \
echo "store-dir=/buildcache/pnpm-store" >> /usr/local/etc/npmrc && \
+8 -8
View File
@@ -49,14 +49,14 @@
"@nestjs/websockets": "^11.0.4",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/context-async-hooks": "^2.0.0",
"@opentelemetry/exporter-prometheus": "^0.219.0",
"@opentelemetry/instrumentation-http": "^0.219.0",
"@opentelemetry/instrumentation-ioredis": "^0.67.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.65.0",
"@opentelemetry/instrumentation-pg": "^0.71.0",
"@opentelemetry/exporter-prometheus": "^0.220.0",
"@opentelemetry/instrumentation-http": "^0.220.0",
"@opentelemetry/instrumentation-ioredis": "^0.68.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.66.0",
"@opentelemetry/instrumentation-pg": "^0.72.0",
"@opentelemetry/resources": "^2.0.1",
"@opentelemetry/sdk-metrics": "^2.0.1",
"@opentelemetry/sdk-node": "^0.219.0",
"@opentelemetry/sdk-node": "^0.220.0",
"@opentelemetry/semantic-conventions": "^1.34.0",
"@react-email/components": "^1.0.0",
"@react-email/render": "^2.0.0",
@@ -84,7 +84,7 @@
"jose": "^6.0.0",
"js-yaml": "^4.1.0",
"jsonwebtoken": "^9.0.2",
"kysely": "0.28.17",
"kysely": "0.29.2",
"kysely-postgres-js": "^3.0.0",
"lodash": "^4.17.21",
"luxon": "^3.4.2",
@@ -116,7 +116,7 @@
"ua-parser-js": "^2.0.0",
"uuid": "^14.0.0",
"validator": "^13.12.0",
"zod": "4.3.6"
"zod": "4.4.3"
},
"devDependencies": {
"@eslint/js": "^10.0.0",
+1 -1
View File
@@ -28,7 +28,7 @@ export class SearchController {
constructor(private service: SearchService) {}
@Post('metadata')
@Authenticated({ permission: Permission.AssetRead })
@Authenticated({ permission: Permission.AssetRead, sharedLink: true })
@HttpCode(HttpStatus.OK)
@Endpoint({
summary: 'Search assets by metadata',
+3
View File
@@ -12,6 +12,9 @@ const JsonSchemaPropertySchema = z
description: z.string().describe('Description'),
default: z.any().optional().describe('Default value'),
enum: z.array(z.string()).optional().describe('Valid choices for enum types'),
minimum: z.number().optional().describe('Minimum value for number types'),
maximum: z.number().optional().describe('Maximum value for number types'),
precision: z.number().default(1).optional().describe('Smallest interval (granularity) for number types'),
array: z.boolean().optional().describe('Type is an array type'),
required: z.array(z.string()).optional().describe('A list of required properties'),
uiHint: z
+6 -1
View File
@@ -74,7 +74,12 @@ const SystemConfigIntegrityJobSchema = z
const SystemConfigIntegrityChecksumJobSchema = SystemConfigIntegrityJobSchema.extend({
timeLimit: z.int().nonnegative().describe('How long the integrity checksum job may run for'),
percentageLimit: z.int().nonnegative().describe('Percentage limit of the integrity checksum job'),
percentageLimit: z
.float32()
.nonnegative()
.max(1)
.describe('Percentage limit of the integrity checksum job')
.meta({ format: 'double' }),
})
.describe('Integrity checksum job config')
.meta({ id: 'SystemConfigIntegrityChecksumJob' });
+1 -1
View File
@@ -86,7 +86,7 @@ select
from
"asset_file"
-- IntegrityRepository.streamAssetPaths
-- IntegrityRepository.streamAssetPathsForMissingFiles
select
"allPaths"."path" as "path",
"allPaths"."assetId",
@@ -119,7 +119,7 @@ export class IntegrityRepository {
}
@GenerateSql({ params: [], stream: true })
streamAssetPaths() {
streamAssetPathsForMissingFiles() {
return this.db
.selectFrom((eb) =>
eb
@@ -143,7 +143,7 @@ export class IntegrityRepository {
)
.leftJoin('integrity_report', (join) =>
join
.on('integrity_report.type', '=', IntegrityReport.UntrackedFile)
.on('integrity_report.type', '=', IntegrityReport.MissingFile)
.on((eb) =>
eb.or([
eb('integrity_report.assetId', '=', eb.ref('allPaths.assetId')),
+57 -33
View File
@@ -3,10 +3,9 @@ import { ExifDateTime, exiftool, WriteTags } from 'exiftool-vendored';
import ffmpeg, { FfprobeData, FfprobeStream } from 'fluent-ffmpeg';
import _ from 'lodash';
import { Duration } from 'luxon';
import { execFile as execFileCb } from 'node:child_process';
import { spawn } from 'node:child_process';
import fs from 'node:fs/promises';
import { Writable } from 'node:stream';
import { promisify } from 'node:util';
import sharp from 'sharp';
import { ORIENTATION_TO_SHARP_ROTATION } from 'src/constants';
import { Exif } from 'src/database';
@@ -44,8 +43,6 @@ const probe = (input: string, options: string[]): Promise<FfprobeData> =>
ffmpeg.ffprobe(input, options, (error, data) => (error ? reject(error) : resolve(data))),
);
const execFile = promisify(execFileCb);
sharp.concurrency(0);
sharp.cache({ files: 0 });
@@ -291,33 +288,37 @@ export class MediaRepository {
* 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.
*/
async probePackets(input: string, streamIndex: number): Promise<VideoPacketInfo | null> {
const { stdout } = await execFile('ffprobe', [
'-v',
'error',
'-select_streams',
String(streamIndex),
'-show_entries',
'packet=pts,duration,flags',
'-of',
'csv=p=0',
input,
]);
probePackets(input: string, streamIndex: number): Promise<VideoPacketInfo | null> {
const ffprobe = spawn(
'ffprobe',
[
'-v',
'error',
'-select_streams',
String(streamIndex),
'-show_entries',
'packet=pts,duration,flags',
'-of',
'csv=p=0',
input,
],
{ stdio: ['ignore', 'pipe', 'pipe'] },
);
let totalDuration = 0;
const keyframePts: number[] = [];
const keyframeAccDuration: number[] = [];
const keyframeOwnDuration: number[] = [];
const postDiscard: { pts: number; duration: number }[] = [];
for (const line of stdout.split('\n')) {
const parseLine = (line: string) => {
if (!line) {
continue;
return;
}
const [ptsStr, durationStr, flags] = line.split(',');
const pts = Number.parseInt(ptsStr);
const duration = Number.parseInt(durationStr);
if (Number.isNaN(pts) || Number.isNaN(duration)) {
continue;
if (Number.isNaN(pts) || Number.isNaN(duration) || !flags) {
return;
}
// Discarded packets don't contribute to packet count, but still contribute to video duration
totalDuration += duration;
@@ -332,20 +333,43 @@ export class MediaRepository {
// Non-keyframes are accounted for in totalDuration.
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> {
+67 -1
View File
@@ -407,7 +407,7 @@ export class DatabaseBackupService {
progressCb?.('restore', progress);
});
await pipeline(sqlStream, progressSource, psql, progressSink);
await pipeline(sqlStream, createSqlOwnerTransformStream(databaseUsername), progressSource, psql, progressSink);
try {
progressCb?.('migrations', 0.9);
@@ -572,3 +572,69 @@ function createSqlProgressStreams(cb: (progress: number) => void) {
return [source, sink];
}
function createSqlOwnerTransformStream(databaseUsername: string) {
const OWNER_MARKER_START = new TextEncoder().encode('OWNER TO ');
const DATA_MARKER_START = new TextEncoder().encode('FROM stdin');
const LINE_END = new TextEncoder().encode(';');
const owner = new TextEncoder().encode(databaseUsername);
let ownerSequenceIndex = 0;
let replacingOwnerIndex = 0;
let replacingOwner = false;
let readingDataIndex = 0;
let dataPart = false;
return new PassThrough({
transform(chunk, _encoding, callback) {
let result = chunk;
if (!dataPart) {
for (let index = 0; index < result.length; index++) {
if (replacingOwner) {
if (result[index] === LINE_END[0]) {
result = Buffer.concat([result.slice(0, index), owner.slice(replacingOwnerIndex), result.slice(index)]);
replacingOwnerIndex = owner.length;
} else {
result[index] = owner[replacingOwnerIndex];
replacingOwnerIndex++;
}
}
if (replacingOwnerIndex === owner.length) {
replacingOwner = false;
}
if (result[index] === OWNER_MARKER_START[ownerSequenceIndex]) {
ownerSequenceIndex++;
} else {
ownerSequenceIndex = 0;
}
if (ownerSequenceIndex === OWNER_MARKER_START.length) {
ownerSequenceIndex = 0;
replacingOwner = true;
replacingOwnerIndex = 0;
}
if (result[index] === DATA_MARKER_START[readingDataIndex]) {
readingDataIndex++;
} else {
readingDataIndex = 0;
}
if (readingDataIndex === DATA_MARKER_START.length) {
dataPart = true;
break;
}
}
}
this.push(result);
callback();
},
});
}
+1 -1
View File
@@ -360,7 +360,7 @@ export class IntegrityService extends BaseService {
this.logger.log(`Scanning for missing files...`);
const assetPaths = this.integrityRepository.streamAssetPaths();
const assetPaths = this.integrityRepository.streamAssetPathsForMissingFiles();
let total = 0;
for await (const batchPaths of chunk(assetPaths, JOBS_LIBRARY_PAGINATION_SIZE)) {
+2
View File
@@ -77,6 +77,8 @@ export class SearchService extends BaseService {
if (dto.albumIds && dto.albumIds.length > 0) {
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 {
userIds = await this.getUserIdsToSearch(auth, dto.visibility);
}
+4
View File
@@ -14,6 +14,10 @@ describe('asDateString', () => {
const date = new Date(2000, 0, 15); // 15 Jan 2000, local midnight
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', () => {
+2 -6
View File
@@ -1,4 +1,5 @@
import { ArgumentMetadata, FileValidator, Injectable, ParseUUIDPipe } from '@nestjs/common';
import { DateTime } from 'luxon';
import { createZodDto } from 'nestjs-zod';
import sanitize from 'sanitize-filename';
import { isIP, isIPRange } from 'validator';
@@ -173,12 +174,7 @@ export const isoDateToDate = z
z.date(),
{
decode: (isoString) => new Date(isoString),
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}`;
},
encode: (date) => DateTime.fromJSDate(date).toFormat('yyyy-MM-dd'),
},
)
.meta({ example: '2024-01-01' });
@@ -408,7 +408,7 @@ describe(IntegrityService.name, () => {
} = await ctx.newAsset({ ownerId, originalPath: '/path/to/file2' });
const { id: reportId } = await ctx.get(IntegrityRepository).create({
type: IntegrityReport.UntrackedFile,
type: IntegrityReport.MissingFile,
path: '/path/to/file2',
assetId: assetId2,
});
+2 -2
View File
@@ -76,7 +76,7 @@
"@koddsson/eslint-plugin-tscompat": "^0.2.0",
"@socket.io/component-emitter": "^3.1.0",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/enhanced-img": "^0.10.4",
"@sveltejs/enhanced-img": "^0.11.0",
"@sveltejs/kit": "^2.56.1",
"@sveltejs/vite-plugin-svelte": "7.1.2",
"@tailwindcss/vite": "^4.2.4",
@@ -105,7 +105,7 @@
"prettier-plugin-sort-json": "^4.1.1",
"prettier-plugin-svelte": "^4.0.0",
"rollup-plugin-visualizer": "^7.0.0",
"svelte": "5.56.3",
"svelte": "5.56.4",
"svelte-check": "^4.4.6",
"svelte-eslint-parser": "^1.3.3",
"tailwindcss": "^4.2.4",
@@ -100,7 +100,7 @@
</Field>
{:else if schema.type === 'number'}
<Field {label} {description}>
<NumberInput bind:value={getNumber, setValue} />
<NumberInput bind:value={getNumber, setValue} step={schema.precision} min={schema.minimum} max={schema.maximum} />
</Field>
{:else if schema.type === 'string'}
<Field {label} {description}>
@@ -247,7 +247,7 @@
>
<ImageThumbnail
class={['absolute group-focus-visible:rounded-lg', { 'rounded-xl': selected }, imageClass]}
brokenAssetClass={['z-1 absolute group-focus-visible:rounded-lg', { 'rounded-xl': selected }, brokenAssetClass]}
brokenAssetClass={['z-1 absolute group-focus-visible:rounded-lg', selected && 'rounded-2xl', brokenAssetClass]}
url={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Thumbnail, cacheKey: asset.thumbhash })}
altText={$getAltText(asset)}
widthStyle="{width}px"
+4
View File
@@ -12,6 +12,10 @@ describe('cleanClass', () => {
it('should unnest arrays', () => {
expect(cleanClass('class1', ['class2', 'class3'])).toBe('class1 class2 class3');
});
it('should unnest doubly nested arrays', () => {
expect(cleanClass('class1', [['class2a', 'class2b'], 'class3'])).toBe('class1 class2a class2b class3');
});
});
describe('isDefined', () => {
+4 -2
View File
@@ -3,8 +3,7 @@ import { twMerge } from 'tailwind-merge';
export const cleanClass = (...classNames: unknown[]) => {
return twMerge(
classNames
.flatMap((className) => (Array.isArray(className) ? className : [className]))
flattenClass(classNames)
.filter((className) => {
if (!className || typeof className === 'boolean') {
return false;
@@ -16,6 +15,9 @@ export const cleanClass = (...classNames: unknown[]) => {
);
};
const flattenClass = (classNames: unknown[]): unknown[] =>
classNames.flatMap((className) => (Array.isArray(className) ? flattenClass(className) : [className]));
export const isDefined = <T>(value: T): value is NonNullable<T> => value !== null && value !== undefined;
export const getHeatmapRange = () => {
+3
View File
@@ -93,6 +93,9 @@ export type JSONSchemaProperty = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
default?: any;
enum?: string[];
minimum?: number;
maximum?: number;
precision?: number;
array?: boolean;
properties?: Record<string, JSONSchemaProperty>;
required?: string[];
@@ -242,6 +242,7 @@
function removeFilter(key: keyof SearchTerms) {
delete terms[key];
assetMultiSelectManager.clear();
void goto(Route.search(terms));
}
</script>