mirror of
https://github.com/immich-app/immich.git
synced 2026-06-16 11:52:16 -07:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a5d998bf1 | |||
| dbe352e97a | |||
| 0f49bcbd27 | |||
| df4a708aed | |||
| 34f78e3fd4 | |||
| 3c2296b818 | |||
| 805ca1a2bf | |||
| 9ee412110f | |||
| 010220d588 | |||
| 10fddf2d51 | |||
| d307ab60ca | |||
| ce59cc9241 | |||
| 1fa034125c | |||
| 8036dc4b8c | |||
| 54895fb10e | |||
| e70a1163f3 | |||
| a23a7c69ae | |||
| f21a753aff | |||
| cc8d3b4107 | |||
| 622a330d82 | |||
| 5e8744a568 | |||
| b633cc4f04 | |||
| a9ee6a7ce9 | |||
| c273ccf2e2 | |||
| 5f1a180d1a | |||
| cc54de87aa | |||
| a97e5999e4 | |||
| 46631b3786 | |||
| 5a3be158b9 |
@@ -237,7 +237,7 @@ jobs:
|
||||
run: flutter build ios --config-only --no-codesign
|
||||
|
||||
- name: Setup Ruby
|
||||
uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1.310.0
|
||||
uses: ruby/setup-ruby@12fd324f1d0b43274fdc8130f6980590a667c455 # v1.312.0
|
||||
with:
|
||||
ruby-version: '3.3'
|
||||
bundler-cache: true
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check for breaking API changes
|
||||
uses: oasdiff/oasdiff-action/breaking@a8c7f0e5649d20d623edb5b38446d3ab3d82d43c # v0.0.53
|
||||
uses: oasdiff/oasdiff-action/breaking@3530478ec30f84adedbfeb28f0d9527a290f50a9 # v0.0.57
|
||||
with:
|
||||
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
|
||||
revision: open-api/immich-openapi-specs.json
|
||||
|
||||
+1
-2
@@ -32,7 +32,7 @@
|
||||
"@playwright/test": "^1.44.1",
|
||||
"@socket.io/component-emitter": "^3.1.2",
|
||||
"@types/luxon": "^3.4.2",
|
||||
"@types/node": "^24.12.4",
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/pg": "^8.15.1",
|
||||
"@types/pngjs": "^6.0.4",
|
||||
"@types/supertest": "^7.0.0",
|
||||
@@ -54,7 +54,6 @@
|
||||
"typescript": "^6.0.0",
|
||||
"typescript-eslint": "^8.28.0",
|
||||
"utimes": "^5.2.1",
|
||||
"vite-tsconfig-paths": "^6.1.1",
|
||||
"vitest": "^4.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { AssetVisibility, LoginResponseDto } from '@immich/sdk';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { basename, join } from 'node:path';
|
||||
import { Socket } from 'socket.io-client';
|
||||
import { createUserDto } from 'src/fixtures';
|
||||
import { errorDto } from 'src/responses';
|
||||
import { app, testAssetDir, utils } from 'src/utils';
|
||||
import request from 'supertest';
|
||||
@@ -9,28 +10,48 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
describe('/map', () => {
|
||||
let websocket: Socket;
|
||||
let partnerWebsocket: Socket;
|
||||
let admin: LoginResponseDto;
|
||||
let partner: LoginResponseDto;
|
||||
let partnerArchivedAssetId: string;
|
||||
let adminArchivedAssetId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await utils.resetDatabase();
|
||||
admin = await utils.adminSetup({ onboarding: false });
|
||||
partner = await utils.userSetup(admin.accessToken, createUserDto.user1);
|
||||
|
||||
websocket = await utils.connectWebsocket(admin.accessToken);
|
||||
partnerWebsocket = await utils.connectWebsocket(partner.accessToken);
|
||||
|
||||
const files = ['formats/heic/IMG_2682.heic', 'metadata/gps-position/thompson-springs.jpg'];
|
||||
const adminFiles = ['formats/heic/IMG_2682.heic', 'metadata/gps-position/thompson-springs.jpg'];
|
||||
const adminArchivedFile = 'metadata/dates/datetimeoriginal-gps.jpg';
|
||||
const partnerFile = 'metadata/gps-position/thompson-springs.jpg';
|
||||
utils.resetEvents();
|
||||
const uploadFile = async (input: string) => {
|
||||
const uploadFile = async (accessToken: string, input: string) => {
|
||||
const filepath = join(testAssetDir, input);
|
||||
const { id } = await utils.createAsset(admin.accessToken, {
|
||||
const { id } = await utils.createAsset(accessToken, {
|
||||
assetData: { bytes: await readFile(filepath), filename: basename(filepath) },
|
||||
});
|
||||
await utils.waitForWebsocketEvent({ event: 'assetUpload', id });
|
||||
return id;
|
||||
};
|
||||
await Promise.all(files.map((f) => uploadFile(f)));
|
||||
await Promise.all(adminFiles.map((f) => uploadFile(admin.accessToken, f)));
|
||||
[adminArchivedAssetId, partnerArchivedAssetId] = await Promise.all([
|
||||
uploadFile(admin.accessToken, adminArchivedFile),
|
||||
uploadFile(partner.accessToken, partnerFile),
|
||||
]);
|
||||
|
||||
await Promise.all([
|
||||
utils.archiveAssets(admin.accessToken, [adminArchivedAssetId]),
|
||||
utils.archiveAssets(partner.accessToken, [partnerArchivedAssetId]),
|
||||
utils.createPartner(partner.accessToken, admin.userId),
|
||||
]);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
utils.disconnectWebsocket(websocket);
|
||||
utils.disconnectWebsocket(partnerWebsocket);
|
||||
});
|
||||
|
||||
describe('GET /map/markers', () => {
|
||||
@@ -40,7 +61,6 @@ describe('/map', () => {
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
// TODO archive one of these assets
|
||||
it('should get map markers for all non-archived assets', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/map/markers')
|
||||
@@ -69,7 +89,28 @@ describe('/map', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
// TODO archive one of these assets
|
||||
it('should not expose partner archived asset locations', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/map/markers')
|
||||
.query({ withPartners: true, isArchived: true })
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
const ids = body.map((m: { id: string }) => m.id);
|
||||
expect(ids).not.toContain(partnerArchivedAssetId);
|
||||
expect(ids).toContain(adminArchivedAssetId);
|
||||
});
|
||||
|
||||
it('should include own archived asset locations', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/map/markers')
|
||||
.query({ isArchived: true })
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.map((m: { id: string }) => m.id)).toContain(adminArchivedAssetId);
|
||||
});
|
||||
|
||||
it('should get all map markers', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/map/markers')
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
const skipDockerSetup = process.env.VITEST_DISABLE_DOCKER_SETUP === 'true';
|
||||
@@ -24,5 +23,7 @@ export default defineConfig({
|
||||
maxWorkers: 1,
|
||||
isolate: false,
|
||||
},
|
||||
plugins: [tsconfigPaths()],
|
||||
resolve: {
|
||||
tsconfigPaths: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
const skipDockerSetup = process.env.VITEST_DISABLE_DOCKER_SETUP === 'true';
|
||||
@@ -24,5 +23,7 @@ export default defineConfig({
|
||||
maxWorkers: 1,
|
||||
isolate: false,
|
||||
},
|
||||
plugins: [tsconfigPaths()],
|
||||
resolve: {
|
||||
tsconfigPaths: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -189,18 +189,23 @@
|
||||
"machine_learning_smart_search_enabled": "Enable smart search",
|
||||
"machine_learning_smart_search_enabled_description": "If disabled, images will not be encoded for smart search.",
|
||||
"machine_learning_url_description": "The URL of the machine learning server. If more than one URL is provided, each server will be attempted one-at-a-time until one responds successfully, in order from first to last. Servers that don't respond will be temporarily ignored until they come back online.",
|
||||
"maintenance_backup_management": "Backup management",
|
||||
"maintenance_delete_backup": "Delete Backup",
|
||||
"maintenance_delete_backup_description": "This file will be irrevocably deleted.",
|
||||
"maintenance_delete_error": "Failed to delete backup.",
|
||||
"maintenance_integrity_check": "Check",
|
||||
"maintenance_integrity_check_all": "Check All",
|
||||
"maintenance_integrity_checksum_mismatch": "Checksum Mismatch",
|
||||
"maintenance_integrity_checksum_mismatch_description": "Files whose on-disk checksum mismatches the checksum Immich has stored in its database.",
|
||||
"maintenance_integrity_checksum_mismatch_job": "Check for checksum mismatches",
|
||||
"maintenance_integrity_checksum_mismatch_refresh_job": "Refresh checksum mismatch reports",
|
||||
"maintenance_integrity_missing_file": "Missing Files",
|
||||
"maintenance_integrity_missing_file_description": "Files that Immich has tracked in its database but do not exist on the file system.",
|
||||
"maintenance_integrity_missing_file_job": "Check for missing files",
|
||||
"maintenance_integrity_missing_file_refresh_job": "Refresh missing file reports",
|
||||
"maintenance_integrity_report": "Integrity Report",
|
||||
"maintenance_integrity_untracked_file": "Untracked Files",
|
||||
"maintenance_integrity_untracked_file_description": "Files in Immich's directories that Immich does not have any record of.",
|
||||
"maintenance_integrity_untracked_file_job": "Check for untracked files",
|
||||
"maintenance_integrity_untracked_file_refresh_job": "Refresh untracked file reports",
|
||||
"maintenance_restore_backup": "Restore Backup",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -82,6 +82,19 @@ url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/353224133"
|
||||
version = "7.1.3-6"
|
||||
backend = "github:jellyfin/jellyfin-ffmpeg"
|
||||
|
||||
[tools."github:jellyfin/jellyfin-ffmpeg".options]
|
||||
asset_pattern = "jellyfin-ffmpeg_*_portable_linuxarm64-gpl.tar.xz"
|
||||
|
||||
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64"]
|
||||
checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886"
|
||||
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz"
|
||||
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876"
|
||||
|
||||
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64-musl"]
|
||||
checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886"
|
||||
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz"
|
||||
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876"
|
||||
|
||||
[[tools."github:webassembly/binaryen"]]
|
||||
version = "version_124"
|
||||
backend = "github:webassembly/binaryen"
|
||||
|
||||
@@ -2,7 +2,6 @@ package app.alextran.immich.sync
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.MediaStore
|
||||
@@ -11,7 +10,6 @@ import androidx.annotation.RequiresExtension
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.InputStream
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
@RequiresExtension(extension = Build.VERSION_CODES.R, version = 1)
|
||||
@@ -126,12 +124,4 @@ class NativeSyncApiImpl30(context: Context) : NativeSyncApiImplBase(context), Na
|
||||
|
||||
return result.mapValues { it.value.toList() }
|
||||
}
|
||||
|
||||
override fun openOriginalStream(uri: Uri): InputStream? {
|
||||
return try {
|
||||
ctx.contentResolver.openInputStream(MediaStore.setRequireOriginal(uri))
|
||||
} catch (_: Exception) {
|
||||
super.openOriginalStream(uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import android.annotation.SuppressLint
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.ext.SdkExtensions
|
||||
@@ -30,7 +30,6 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import java.security.MessageDigest
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
@@ -276,7 +275,7 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to parse image header for asset $assetId", e)
|
||||
}
|
||||
// if mimeType is webp but not animated, it's just an image.
|
||||
// if mimeType is webp but not animated, its just an image.
|
||||
return PlatformAssetPlaybackStyle.IMAGE
|
||||
}
|
||||
|
||||
@@ -378,11 +377,7 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
|
||||
)?.use { cursor -> cursor.count.toLong() } ?: 0L
|
||||
|
||||
|
||||
fun getAssetsForAlbum(
|
||||
albumId: String,
|
||||
updatedTimeCond: Long?,
|
||||
callback: (Result<List<PlatformAsset>>) -> Unit
|
||||
) {
|
||||
fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?, callback: (Result<List<PlatformAsset>>) -> Unit) {
|
||||
runSync(callback) { getAssetsForAlbum(albumId, updatedTimeCond) }
|
||||
}
|
||||
|
||||
@@ -424,7 +419,7 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
|
||||
}.awaitAll()
|
||||
|
||||
completeWhenActive(callback, Result.success(results))
|
||||
} catch (_: CancellationException) {
|
||||
} catch (e: CancellationException) {
|
||||
completeWhenActive(
|
||||
callback, Result.failure(
|
||||
FlutterError(
|
||||
@@ -440,10 +435,6 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
|
||||
}
|
||||
}
|
||||
|
||||
protected open fun openOriginalStream(uri: Uri): InputStream? {
|
||||
return ctx.contentResolver.openInputStream(uri)
|
||||
}
|
||||
|
||||
private suspend fun hashAsset(assetId: String): HashResult {
|
||||
return try {
|
||||
val assetUri = ContentUris.withAppendedId(
|
||||
@@ -452,7 +443,7 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
|
||||
)
|
||||
|
||||
val digest = MessageDigest.getInstance("SHA-1")
|
||||
openOriginalStream(assetUri)?.use { inputStream ->
|
||||
ctx.contentResolver.openInputStream(assetUri)?.use { inputStream ->
|
||||
var bytesRead: Int
|
||||
val buffer = ByteArray(HASH_BUFFER_SIZE)
|
||||
while (inputStream.read(buffer).also { bytesRead = it } > 0) {
|
||||
@@ -485,11 +476,8 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
|
||||
syncJob = CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
completeWhenActive(callback, Result.success(work()))
|
||||
} catch (_: CancellationException) {
|
||||
completeWhenActive(
|
||||
callback,
|
||||
Result.failure(FlutterError(SYNC_CANCELLED_CODE, "Sync cancelled", null))
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
completeWhenActive(callback, Result.failure(FlutterError(SYNC_CANCELLED_CODE, "Sync cancelled", null)))
|
||||
} catch (e: Exception) {
|
||||
completeWhenActive(callback, Result.failure(e))
|
||||
}
|
||||
|
||||
@@ -15,8 +15,10 @@ const Map<String, Locale> locales = {
|
||||
'Czech (cs)': Locale('cs'),
|
||||
'Danish (da)': Locale('da'),
|
||||
'Dutch (nl)': Locale('nl'),
|
||||
'English (United Kingdom) (en_GB)': Locale('en', 'GB'),
|
||||
'Estonian (et)': Locale('et'),
|
||||
'Filipino (tl)': Locale('tl'),
|
||||
'Filipino (fil)': Locale('fil'),
|
||||
'Filipino (Tagalog) (tl)': Locale('tl'),
|
||||
'Finnish (fi)': Locale('fi'),
|
||||
'French (fr)': Locale('fr'),
|
||||
'Galician (gl)': Locale('gl'),
|
||||
|
||||
@@ -142,7 +142,6 @@ class AppConfig {
|
||||
.cleanupCutoffDaysAgo => cleanup.cutoffDaysAgo,
|
||||
.cleanupDefaultsInitialized => cleanup.defaultsInitialized,
|
||||
.shareFileType => share.fileType,
|
||||
.slideshowTransition => slideshow.transition,
|
||||
.slideshowRepeat => slideshow.repeat,
|
||||
.slideshowDuration => slideshow.duration,
|
||||
.slideshowLook => slideshow.look,
|
||||
@@ -196,7 +195,6 @@ class AppConfig {
|
||||
.cleanupCutoffDaysAgo => copyWith(cleanup: cleanup.copyWith(cutoffDaysAgo: value as int)),
|
||||
.cleanupDefaultsInitialized => copyWith(cleanup: cleanup.copyWith(defaultsInitialized: value as bool)),
|
||||
.shareFileType => copyWith(share: share.copyWith(fileType: value as ShareAssetType)),
|
||||
.slideshowTransition => copyWith(slideshow: slideshow.copyWith(transition: value as bool)),
|
||||
.slideshowRepeat => copyWith(slideshow: slideshow.copyWith(repeat: value as bool)),
|
||||
.slideshowDuration => copyWith(slideshow: slideshow.copyWith(duration: value as int)),
|
||||
.slideshowLook => copyWith(slideshow: slideshow.copyWith(look: value as SlideshowLook)),
|
||||
|
||||
@@ -1,48 +1,38 @@
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
|
||||
class SlideshowConfig {
|
||||
final bool transition;
|
||||
final bool repeat;
|
||||
final int duration;
|
||||
final SlideshowLook look;
|
||||
final SlideshowDirection direction;
|
||||
|
||||
const SlideshowConfig({
|
||||
this.transition = true,
|
||||
this.repeat = true,
|
||||
this.duration = 5,
|
||||
this.look = SlideshowLook.contain,
|
||||
this.look = SlideshowLook.blurredBackground,
|
||||
this.direction = SlideshowDirection.forward,
|
||||
});
|
||||
|
||||
SlideshowConfig copyWith({
|
||||
bool? transition,
|
||||
bool? repeat,
|
||||
int? duration,
|
||||
SlideshowLook? look,
|
||||
SlideshowDirection? direction,
|
||||
}) => SlideshowConfig(
|
||||
transition: transition ?? this.transition,
|
||||
repeat: repeat ?? this.repeat,
|
||||
duration: duration ?? this.duration,
|
||||
look: look ?? this.look,
|
||||
direction: direction ?? this.direction,
|
||||
);
|
||||
SlideshowConfig copyWith({bool? repeat, int? duration, SlideshowLook? look, SlideshowDirection? direction}) =>
|
||||
SlideshowConfig(
|
||||
repeat: repeat ?? this.repeat,
|
||||
duration: duration ?? this.duration,
|
||||
look: look ?? this.look,
|
||||
direction: direction ?? this.direction,
|
||||
);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
(other is SlideshowConfig &&
|
||||
other.transition == transition &&
|
||||
other.repeat == repeat &&
|
||||
other.duration == duration &&
|
||||
other.look == look &&
|
||||
other.direction == direction);
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(transition, repeat, duration, look, direction);
|
||||
int get hashCode => Object.hash(repeat, duration, look, direction);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'SlideshowConfig(transition: $transition, repeat: $repeat, duration: $duration, look: $look, direction: $direction)';
|
||||
String toString() => 'SlideshowConfig(repeat: $repeat, duration: $duration, look: $look, direction: $direction)';
|
||||
}
|
||||
|
||||
@@ -70,7 +70,6 @@ enum SettingsKey<T> {
|
||||
shareFileType<ShareAssetType>(codec: _EnumCodec(ShareAssetType.values)),
|
||||
|
||||
// Slideshow
|
||||
slideshowTransition<bool>(),
|
||||
slideshowRepeat<bool>(),
|
||||
slideshowDuration<int>(),
|
||||
slideshowLook<SlideshowLook>(codec: _EnumCodec(SlideshowLook.values)),
|
||||
|
||||
@@ -104,6 +104,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
Future<void> onAndroidUpload(int? maxMinutes) async {
|
||||
final hashTimeout = Duration(minutes: _isBackupEnabled ? 3 : 6);
|
||||
final backupTimeout = maxMinutes != null ? Duration(minutes: maxMinutes - 1) : null;
|
||||
await _optimizeDB();
|
||||
return _backgroundLoop(
|
||||
hashTimeout: hashTimeout,
|
||||
backupTimeout: backupTimeout,
|
||||
@@ -123,6 +124,11 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only for Background Processing tasks
|
||||
if (maxSeconds == null) {
|
||||
await _optimizeDB();
|
||||
}
|
||||
|
||||
// Run sync local, sync remote, hash and backup concurrently so the bg
|
||||
// refresh task (20s budget) can make progress on all four instead of
|
||||
// racing them sequentially. Phases are independent at the data layer:
|
||||
@@ -193,6 +199,14 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _optimizeDB() async {
|
||||
try {
|
||||
await (_drift.optimize(allTables: true), _driftLogger.optimize()).wait;
|
||||
} catch (error, stack) {
|
||||
dPrint(() => "Error during background worker optimize: $error, $stack");
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _cleanup() async {
|
||||
await runZonedGuarded(_handleCleanup, (error, stack) {
|
||||
dPrint(() => "Error during background worker cleanup: $error, $stack");
|
||||
@@ -221,7 +235,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
if (nativeSyncApi != null) nativeSyncApi.cancelHashing(),
|
||||
]);
|
||||
await workerManagerPatch.dispose().catchError((_) async {});
|
||||
await Future.wait([LogService.I.dispose(), Store.dispose(), _drift.optimize(allTables: true)]);
|
||||
await Future.wait([LogService.I.dispose(), Store.dispose()]);
|
||||
await _drift.close();
|
||||
await _driftLogger.close();
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import 'package:immich_mobile/domain/services/store.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/store.provider.dart';
|
||||
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
@@ -19,7 +18,6 @@ final syncLinkedAlbumServiceProvider = Provider(
|
||||
ref.watch(remoteAlbumRepository),
|
||||
ref.watch(driftAlbumApiRepositoryProvider),
|
||||
ref.watch(storeServiceProvider),
|
||||
cancellation: ref.watch(cancellationProvider),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -28,19 +26,17 @@ class SyncLinkedAlbumService {
|
||||
final DriftRemoteAlbumRepository _remoteAlbumRepository;
|
||||
final DriftAlbumApiRepository _albumApiRepository;
|
||||
final StoreService _storeService;
|
||||
final Completer<void>? _cancellation;
|
||||
|
||||
SyncLinkedAlbumService(
|
||||
this._localAlbumRepository,
|
||||
this._remoteAlbumRepository,
|
||||
this._albumApiRepository,
|
||||
this._storeService, {
|
||||
this._cancellation,
|
||||
});
|
||||
this._storeService,
|
||||
);
|
||||
|
||||
final _log = Logger("SyncLinkedAlbumService");
|
||||
|
||||
Future<void> syncLinkedAlbums(String userId) async {
|
||||
Future<void> syncLinkedAlbums(String userId, {Completer<void>? cancellation}) async {
|
||||
final selectedAlbums = await _localAlbumRepository.getBackupAlbums();
|
||||
|
||||
await Future.wait(
|
||||
@@ -64,7 +60,7 @@ class SyncLinkedAlbumService {
|
||||
final album = await _albumApiRepository.addAssets(
|
||||
remoteAlbum.id,
|
||||
assetIds,
|
||||
abortTrigger: _cancellation?.future,
|
||||
abortTrigger: cancellation?.future,
|
||||
);
|
||||
await _remoteAlbumRepository.addAssets(remoteAlbum.id, album.added);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
|
||||
import 'package:immich_mobile/utils/async_mutex.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
typedef TimelineAssetSource = Future<List<BaseAsset>> Function(int index, int count);
|
||||
|
||||
@@ -90,6 +91,7 @@ class TimelineFactory {
|
||||
}
|
||||
|
||||
class TimelineService {
|
||||
static final Logger _log = Logger('TimelineService');
|
||||
final TimelineAssetSource _assetSource;
|
||||
final TimelineBucketSource _bucketSource;
|
||||
final TimelineOrigin origin;
|
||||
@@ -105,34 +107,49 @@ class TimelineService {
|
||||
: this._(assetSource: query.assetSource, bucketSource: query.bucketSource, origin: query.origin);
|
||||
|
||||
TimelineService._({required this._assetSource, required this._bucketSource, required this.origin}) {
|
||||
_bucketSubscription = _bucketSource().listen((buckets) {
|
||||
_mutex.run(() async {
|
||||
final totalAssets = buckets.fold<int>(0, (acc, bucket) => acc + bucket.assetCount);
|
||||
_bucketSubscription = _bucketSource().listen(
|
||||
(buckets) {
|
||||
_mutex.run(() async {
|
||||
try {
|
||||
final totalAssets = buckets.fold<int>(0, (acc, bucket) => acc + bucket.assetCount);
|
||||
|
||||
if (totalAssets == 0) {
|
||||
_bufferOffset = 0;
|
||||
_buffer = [];
|
||||
} else {
|
||||
final int offset;
|
||||
final int count;
|
||||
// When the buffer is empty or the old bufferOffset is greater than the new total assets,
|
||||
// we need to reset the buffer and load the first batch of assets.
|
||||
if (_bufferOffset >= totalAssets || _buffer.isEmpty) {
|
||||
offset = 0;
|
||||
count = kTimelineAssetLoadBatchSize;
|
||||
} else {
|
||||
offset = _bufferOffset;
|
||||
count = math.min(_buffer.length, totalAssets - _bufferOffset);
|
||||
_log.info(
|
||||
'[$origin] bucket emission: ${buckets.length} buckets / $totalAssets assets '
|
||||
'(current _totalAssets=$_totalAssets, _bufferOffset=$_bufferOffset, _buffer=${_buffer.length})',
|
||||
);
|
||||
|
||||
if (totalAssets == 0) {
|
||||
_bufferOffset = 0;
|
||||
_buffer = [];
|
||||
} else {
|
||||
final int offset;
|
||||
final int count;
|
||||
// When the buffer is empty or the old bufferOffset is greater than the new total assets,
|
||||
// we need to reset the buffer and load the first batch of assets.
|
||||
if (_bufferOffset >= totalAssets || _buffer.isEmpty) {
|
||||
offset = 0;
|
||||
count = kTimelineAssetLoadBatchSize;
|
||||
} else {
|
||||
offset = _bufferOffset;
|
||||
count = math.min(_buffer.length, totalAssets - _bufferOffset);
|
||||
}
|
||||
_buffer = await _assetSource(offset, count);
|
||||
_bufferOffset = offset;
|
||||
_log.info('[$origin] buffer reloaded: offset=$offset requested=$count got=${_buffer.length}');
|
||||
}
|
||||
|
||||
_totalAssets = totalAssets;
|
||||
EventStream.shared.emit(const TimelineReloadEvent());
|
||||
} catch (error, stack) {
|
||||
_log.severe('[$origin] bucket reload FAILED — _totalAssets stuck at $_totalAssets', error, stack);
|
||||
rethrow;
|
||||
}
|
||||
_buffer = await _assetSource(offset, count);
|
||||
_bufferOffset = offset;
|
||||
}
|
||||
|
||||
// change the state's total assets count only after the buffer is reloaded
|
||||
_totalAssets = totalAssets;
|
||||
EventStream.shared.emit(const TimelineReloadEvent());
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
onError: (Object error, StackTrace stack) {
|
||||
_log.severe('[$origin] bucket stream errored', error, stack);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Stream<List<Bucket>> Function() get watchBuckets => _bucketSource;
|
||||
@@ -164,6 +181,13 @@ class TimelineService {
|
||||
_buffer = await _assetSource(start, len);
|
||||
_bufferOffset = start;
|
||||
|
||||
if (!hasRange(index, count)) {
|
||||
_log.warning(
|
||||
'[$origin] _loadAssets($index, $count): buffer loaded (offset=$start, got=${_buffer.length}) but still '
|
||||
'out of range — _totalAssets=$_totalAssets. getAssets is about to throw RangeError.',
|
||||
);
|
||||
}
|
||||
|
||||
return getAssets(index, count);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/services/sync_linked_album.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
Future<void> syncLinkedAlbumsIsolated(ProviderContainer ref) {
|
||||
@@ -10,5 +11,7 @@ Future<void> syncLinkedAlbumsIsolated(ProviderContainer ref) {
|
||||
Logger("SyncLinkedAlbum").warning("No user logged in, skipping linked album sync");
|
||||
return Future.value();
|
||||
}
|
||||
return ref.read(syncLinkedAlbumServiceProvider).syncLinkedAlbums(user.id);
|
||||
return ref
|
||||
.read(syncLinkedAlbumServiceProvider)
|
||||
.syncLinkedAlbums(user.id, cancellation: ref.read(cancellationProvider));
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:drift/drift.dart';
|
||||
import 'package:drift_sqlite_async/drift_sqlite_async.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/log.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.drift.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
import 'package:sqlite_async/sqlite_async.dart';
|
||||
|
||||
@DriftDatabase(tables: [LogMessageEntity])
|
||||
@@ -13,6 +14,14 @@ class DriftLogger extends $DriftLogger {
|
||||
@override
|
||||
int get schemaVersion => 1;
|
||||
|
||||
Future<void> optimize() async {
|
||||
try {
|
||||
await customStatement('PRAGMA optimize=0x10002');
|
||||
} catch (error) {
|
||||
dPrint(() => 'Failed to optimize logger database: $error');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
beforeOpen: (details) async {
|
||||
|
||||
@@ -33,7 +33,9 @@ class DriftSlideshowPage extends ConsumerStatefulWidget {
|
||||
ConsumerState<DriftSlideshowPage> createState() => _DriftSlideshowPageState();
|
||||
}
|
||||
|
||||
class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> {
|
||||
class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with SingleTickerProviderStateMixin {
|
||||
static const double _kenBurnsZoom = 0.1;
|
||||
|
||||
late SlideshowConfig _config;
|
||||
late final PageController _pageController;
|
||||
late final Stopwatch _stopwatch;
|
||||
@@ -43,6 +45,12 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> {
|
||||
bool _paused = false;
|
||||
bool _showAppBar = false;
|
||||
|
||||
late final AnimationController _crossfadeController;
|
||||
late final Animation<double> _crossfadeOpacity;
|
||||
int? _crossfadeFromIndex;
|
||||
int? _crossfadeToIndex;
|
||||
int _zoomCycle = 0;
|
||||
|
||||
@override
|
||||
initState() {
|
||||
super.initState();
|
||||
@@ -50,6 +58,8 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> {
|
||||
final asset = ref.read(assetViewerProvider).currentAsset;
|
||||
_index = asset == null ? 0 : widget.timeline.getIndex(asset.heroTag) ?? 0;
|
||||
_pageController = PageController(initialPage: _index);
|
||||
_crossfadeController = AnimationController(vsync: this, duration: Durations.extralong2);
|
||||
_crossfadeOpacity = Tween<double>(begin: 1.0, end: 0.0).animate(_crossfadeController);
|
||||
_stopwatch = Stopwatch();
|
||||
_createTimer();
|
||||
_updateNextIndex();
|
||||
@@ -64,6 +74,7 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> {
|
||||
_timer.cancel();
|
||||
_stopwatch.stop();
|
||||
_pageController.dispose();
|
||||
_crossfadeController.dispose();
|
||||
unawaited(WakelockPlus.disable());
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
super.dispose();
|
||||
@@ -150,11 +161,64 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> {
|
||||
await widget.timeline.preloadAssets(_nextIndex);
|
||||
}
|
||||
|
||||
if (_config.direction == SlideshowDirection.shuffle || !_config.transition) {
|
||||
_pageController.jumpToPage(_nextIndex);
|
||||
_crossFadeToPage(_nextIndex);
|
||||
}
|
||||
|
||||
void _crossFadeToPage(int page) {
|
||||
final previousIndex = _index;
|
||||
_pageController.jumpToPage(page);
|
||||
setState(() {
|
||||
_crossfadeFromIndex = previousIndex;
|
||||
_crossfadeToIndex = page;
|
||||
});
|
||||
_crossfadeController.forward(from: 0.0).whenComplete(() {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_crossfadeFromIndex = null;
|
||||
_crossfadeToIndex = null;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Widget _getCrossfadeLayer(BuildContext context, int index, {required bool isIncoming}) {
|
||||
final asset = widget.timeline.getAssetSafe(index);
|
||||
|
||||
final Widget child;
|
||||
if (isIncoming && asset?.isImage == true) {
|
||||
child = _getPhotoView(context, index);
|
||||
} else {
|
||||
unawaited(_pageController.animateToPage(_nextIndex, duration: Durations.long2, curve: Curves.easeIn));
|
||||
final zoomOut = isIncoming ? _zoomCycle.isOdd : _zoomCycle.isEven;
|
||||
final zoom = isIncoming ? (zoomOut ? 1.0 : 0.0) : (zoomOut ? 0.0 : 1.0);
|
||||
child = _getCrossfadeChild(context, index, zoom);
|
||||
}
|
||||
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [if (_config.look == SlideshowLook.blurredBackground) _getBlur(context, index), child],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _getCrossfadeChild(BuildContext context, int index, double zoom) {
|
||||
final asset = widget.timeline.getAssetSafe(index);
|
||||
|
||||
if (asset == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final scale = _config.look == SlideshowLook.cover
|
||||
? PhotoViewComputedScale.covered
|
||||
: PhotoViewComputedScale.contained;
|
||||
|
||||
return PhotoView(
|
||||
imageProvider: getFullImageProvider(asset, size: context.sizeData),
|
||||
index: index,
|
||||
disableScaleGestures: true,
|
||||
gaplessPlayback: true,
|
||||
filterQuality: FilterQuality.high,
|
||||
initialScale: scale * (1.0 + zoom * _kenBurnsZoom),
|
||||
controller: PhotoViewController(),
|
||||
);
|
||||
}
|
||||
|
||||
void _createTimer() {
|
||||
@@ -172,6 +236,7 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> {
|
||||
|
||||
setState(() {
|
||||
_index = page;
|
||||
_zoomCycle++;
|
||||
|
||||
if (!asset.isImage) {
|
||||
_paused = false;
|
||||
@@ -268,7 +333,7 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> {
|
||||
final imageProvider = getFullImageProvider(asset, size: context.sizeData);
|
||||
|
||||
if (asset.isImage) {
|
||||
final zoomOut = index % 2 == 1;
|
||||
final zoomOut = _zoomCycle.isOdd;
|
||||
final elapsed = _stopwatch.elapsedMilliseconds;
|
||||
final duration = _config.duration * 1000;
|
||||
final progress = zoomOut ? 1.0 - elapsed / duration.toDouble() : elapsed / duration.toDouble();
|
||||
@@ -289,7 +354,7 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> {
|
||||
disableScaleGestures: true,
|
||||
gaplessPlayback: true,
|
||||
filterQuality: FilterQuality.high,
|
||||
initialScale: scale * (1.0 + value / 10.0),
|
||||
initialScale: scale * (1.0 + value * _kenBurnsZoom),
|
||||
controller: PhotoViewController(),
|
||||
onTapUp: (_, _, _) => _onTapUp(),
|
||||
),
|
||||
@@ -356,20 +421,43 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> {
|
||||
extendBody: true,
|
||||
extendBodyBehindAppBar: true,
|
||||
backgroundColor: Colors.black,
|
||||
body: PhotoViewGestureDetectorScope(
|
||||
axis: Axis.horizontal,
|
||||
child: PageView.builder(
|
||||
controller: _pageController,
|
||||
physics: const FastClampingScrollPhysics(),
|
||||
itemCount: widget.timeline.totalAssets,
|
||||
onPageChanged: _pageChanged,
|
||||
itemBuilder: (context, index) => Stack(
|
||||
children: [
|
||||
if (_config.look == SlideshowLook.blurredBackground) _getBlur(context, index),
|
||||
_getPhotoView(context, index),
|
||||
],
|
||||
body: Stack(
|
||||
children: [
|
||||
PhotoViewGestureDetectorScope(
|
||||
axis: Axis.horizontal,
|
||||
child: PageView.builder(
|
||||
controller: _pageController,
|
||||
physics: const FastClampingScrollPhysics(),
|
||||
itemCount: widget.timeline.totalAssets,
|
||||
onPageChanged: _pageChanged,
|
||||
itemBuilder: (context, index) => Stack(
|
||||
children: [
|
||||
if (_config.look == SlideshowLook.blurredBackground) _getBlur(context, index),
|
||||
_getPhotoView(context, index),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_crossfadeFromIndex != null && _crossfadeToIndex != null)
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
const ColoredBox(color: Colors.black),
|
||||
FadeTransition(
|
||||
opacity: _crossfadeController,
|
||||
child: _getCrossfadeLayer(context, _crossfadeToIndex!, isIncoming: true),
|
||||
),
|
||||
FadeTransition(
|
||||
opacity: _crossfadeOpacity,
|
||||
child: _getCrossfadeLayer(context, _crossfadeFromIndex!, isIncoming: false),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,10 +6,12 @@ import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/settings_key.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
@@ -51,7 +53,9 @@ class _SharePreparingDialog extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _ShareFileTypeDialog extends StatelessWidget {
|
||||
const _ShareFileTypeDialog();
|
||||
final bool showPreview;
|
||||
|
||||
const _ShareFileTypeDialog({this.showPreview = true});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -66,11 +70,12 @@ class _ShareFileTypeDialog extends StatelessWidget {
|
||||
title: Text(context.t.share_original),
|
||||
onTap: () => context.pop(ShareAssetType.original),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_size_select_large_rounded),
|
||||
title: Text(context.t.share_preview),
|
||||
onTap: () => context.pop(ShareAssetType.preview),
|
||||
),
|
||||
if (showPreview)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_size_select_large_rounded),
|
||||
title: Text(context.t.share_preview),
|
||||
onTap: () => context.pop(ShareAssetType.preview),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [TextButton(onPressed: () => context.pop(), child: Text(context.t.cancel))],
|
||||
@@ -85,6 +90,16 @@ class ShareActionButton extends ConsumerWidget {
|
||||
|
||||
const ShareActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
|
||||
|
||||
Set<BaseAsset> _getSelectedAssets(WidgetRef ref) {
|
||||
return switch (source) {
|
||||
ActionSource.timeline => ref.read(multiSelectProvider).selectedAssets,
|
||||
ActionSource.viewer => switch (ref.read(assetViewerProvider).currentAsset) {
|
||||
BaseAsset asset => {asset},
|
||||
null => const {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
@@ -99,9 +114,14 @@ class ShareActionButton extends ConsumerWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
// only show preview option when at least one of the assets is not a video
|
||||
// we cant share previews of videos
|
||||
final assets = _getSelectedAssets(ref);
|
||||
final showPreview = assets.isEmpty || assets.any((asset) => !asset.isVideo);
|
||||
|
||||
final fileType = await showDialog<ShareAssetType>(
|
||||
context: context,
|
||||
builder: (_) => const _ShareFileTypeDialog(),
|
||||
builder: (_) => _ShareFileTypeDialog(showPreview: showPreview),
|
||||
useRootNavigator: false,
|
||||
);
|
||||
|
||||
|
||||
@@ -23,7 +23,9 @@ class MapBottomSheet extends StatelessWidget {
|
||||
resizeOnScroll: false,
|
||||
actions: [],
|
||||
backgroundColor: context.themeData.colorScheme.surface,
|
||||
slivers: [const SliverFillRemaining(hasScrollBody: true, child: _ScopedMapTimeline())],
|
||||
slivers: [
|
||||
const SliverFillRemaining(hasScrollBody: false, child: SizedBox(height: 0, child: _ScopedMapTimeline())),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.da
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
class FixedSegment extends Segment {
|
||||
final double tileHeight;
|
||||
@@ -90,6 +91,7 @@ class FixedSegment extends Segment {
|
||||
}
|
||||
|
||||
class _FixedSegmentRow extends ConsumerWidget {
|
||||
static final Logger _log = Logger('TimelineRow');
|
||||
final int assetIndex;
|
||||
final int assetCount;
|
||||
final double tileHeight;
|
||||
@@ -109,11 +111,20 @@ class _FixedSegmentRow extends ConsumerWidget {
|
||||
final isScrubbing = ref.watch(timelineStateProvider.select((s) => s.isScrubbing));
|
||||
final timelineService = ref.read(timelineServiceProvider);
|
||||
final isDynamicLayout = columnCount <= (context.isMobile ? 2 : 3);
|
||||
final inRange = timelineService.hasRange(assetIndex, assetCount);
|
||||
|
||||
if (isScrubbing) {
|
||||
return _buildPlaceholder(context);
|
||||
if (assetIndex == 0) {
|
||||
_log.info(
|
||||
'row[0] inRange=$inRange isScrubbing=$isScrubbing totalAssets=${timelineService.totalAssets} '
|
||||
'branch=${inRange
|
||||
? "assets"
|
||||
: isScrubbing
|
||||
? "placeholder(scrubbing)"
|
||||
: "future(load)"}',
|
||||
);
|
||||
}
|
||||
if (timelineService.hasRange(assetIndex, assetCount)) {
|
||||
|
||||
if (inRange) {
|
||||
return _buildAssetRow(
|
||||
context,
|
||||
timelineService.getAssets(assetIndex, assetCount),
|
||||
@@ -122,12 +133,23 @@ class _FixedSegmentRow extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
if (isScrubbing) {
|
||||
return _buildPlaceholder(context);
|
||||
}
|
||||
|
||||
return FutureBuilder<List<BaseAsset>>(
|
||||
future: timelineService.loadAssets(assetIndex, assetCount),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState != ConnectionState.done) {
|
||||
return _buildPlaceholder(context);
|
||||
}
|
||||
if (snapshot.hasError) {
|
||||
_log.warning(
|
||||
'render row loadAssets($assetIndex, $assetCount) failed (totalAssets=${timelineService.totalAssets})',
|
||||
snapshot.error,
|
||||
snapshot.stackTrace,
|
||||
);
|
||||
}
|
||||
return _buildAssetRow(context, snapshot.requireData, timelineService, isDynamicLayout);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@ import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart'
|
||||
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
||||
import 'package:immich_mobile/utils/debounce.dart';
|
||||
import 'package:intl/intl.dart' hide TextDirection;
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
/// A widget that will display a BoxScrollView with a ScrollThumb that can be dragged
|
||||
/// for quick navigation of the BoxScrollView.
|
||||
@@ -84,6 +85,7 @@ List<_Segment> _buildSegments({required List<Segment> layoutSegments, required d
|
||||
}
|
||||
|
||||
class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixin {
|
||||
static final Logger _log = Logger('Scrubber');
|
||||
String? _lastLabel;
|
||||
double _thumbTopOffset = 0.0;
|
||||
bool _isDragging = false;
|
||||
@@ -114,6 +116,7 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_log.info('Scrubber initState');
|
||||
_isDragging = false;
|
||||
_segments = _buildSegments(layoutSegments: widget.layoutSegments, timelineHeight: _scrubberHeight);
|
||||
_thumbAnimationController = AnimationController(vsync: this, duration: kTimelineScrubberFadeInDuration);
|
||||
@@ -134,7 +137,10 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
|
||||
void didUpdateWidget(covariant Scrubber oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
if (oldWidget.layoutSegments.lastOrNull?.endOffset != widget.layoutSegments.lastOrNull?.endOffset) {
|
||||
final oldEnd = oldWidget.layoutSegments.lastOrNull?.endOffset;
|
||||
final newEnd = widget.layoutSegments.lastOrNull?.endOffset;
|
||||
if (oldEnd != newEnd) {
|
||||
_log.info('Scrubber layoutSegments endOffset $oldEnd -> $newEnd (isDragging=$_isDragging)');
|
||||
_segments = _buildSegments(layoutSegments: widget.layoutSegments, timelineHeight: _scrubberHeight);
|
||||
_monthCount = getMonthCount();
|
||||
}
|
||||
@@ -142,6 +148,15 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_isDragging || _currentScrubberDate != null || _scrubberDebouncer != null) {
|
||||
_log.warning(
|
||||
'Scrubber dispose mid-scrub '
|
||||
'(isDragging=$_isDragging, pendingDate=$_currentScrubberDate, '
|
||||
'debouncerPending=${_scrubberDebouncer != null}) — scrubbing reset may be orphaned',
|
||||
);
|
||||
} else {
|
||||
_log.info('Scrubber dispose');
|
||||
}
|
||||
_thumbAnimationController.dispose();
|
||||
_labelAnimationController.dispose();
|
||||
_fadeOutTimer?.cancel();
|
||||
@@ -208,6 +223,7 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
|
||||
}
|
||||
|
||||
void _onDragStart(DragStartDetails _) {
|
||||
_log.info('scrub dragStart');
|
||||
setState(() {
|
||||
_isDragging = true;
|
||||
_labelAnimationController.forward();
|
||||
@@ -222,9 +238,15 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
|
||||
}
|
||||
|
||||
if (_scrubberHeight <= 0) {
|
||||
_log.warning('drag ignored: scrubberHeight=$_scrubberHeight <= 0');
|
||||
return;
|
||||
}
|
||||
|
||||
final maxScrollExtent = _scrollController.hasClients ? _scrollController.position.maxScrollExtent : -1;
|
||||
if (maxScrollExtent <= 0) {
|
||||
_log.warning('drag ineffective: hasClients=${_scrollController.hasClients} maxScrollExtent=$maxScrollExtent');
|
||||
}
|
||||
|
||||
if (_thumbAnimationController.status != AnimationStatus.forward) {
|
||||
_thumbAnimationController.forward();
|
||||
}
|
||||
@@ -344,6 +366,7 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
|
||||
}
|
||||
|
||||
void _onDragEnd(DragEndDetails _) {
|
||||
_log.info('scrub dragEnd -> setScrubbing(false)');
|
||||
_labelAnimationController.reverse();
|
||||
setState(() {
|
||||
_isDragging = false;
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:immich_mobile/presentation/widgets/timeline/fixed/segment_builde
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
class TimelineArgs {
|
||||
final double maxWidth;
|
||||
@@ -71,14 +72,27 @@ class TimelineState {
|
||||
}
|
||||
|
||||
class TimelineStateNotifier extends Notifier<TimelineState> {
|
||||
static final Logger _log = Logger('TimelineState');
|
||||
|
||||
void setScrubbing(bool isScrubbing) {
|
||||
if (state.isScrubbing != isScrubbing) {
|
||||
_log.info('isScrubbing ${state.isScrubbing} -> $isScrubbing (from ${_callSite()})');
|
||||
}
|
||||
state = state.copyWith(isScrubbing: isScrubbing);
|
||||
}
|
||||
|
||||
void setScrolling(bool isScrolling) {
|
||||
if (state.isScrolling != isScrolling) {
|
||||
_log.info('isScrolling ${state.isScrolling} -> $isScrolling (from ${_callSite()})');
|
||||
}
|
||||
state = state.copyWith(isScrolling: isScrolling);
|
||||
}
|
||||
|
||||
static String _callSite() {
|
||||
final frames = StackTrace.current.toString().split('\n');
|
||||
return frames.length > 2 ? frames[2].trim() : 'unknown';
|
||||
}
|
||||
|
||||
@override
|
||||
TimelineState build() => const TimelineState(isScrubbing: false, isScrolling: false);
|
||||
}
|
||||
@@ -96,6 +110,11 @@ final timelineSegmentProvider = StreamProvider.autoDispose<List<Segment>>((ref)
|
||||
|
||||
final timelineService = ref.watch(timelineServiceProvider);
|
||||
yield* timelineService.watchBuckets().map((buckets) {
|
||||
final layoutTotal = buckets.fold<int>(0, (acc, bucket) => acc + bucket.assetCount);
|
||||
Logger('TimelineService').info(
|
||||
'[${timelineService.origin}] segment layout: '
|
||||
'${buckets.length} buckets / $layoutTotal assets (service.totalAssets=${timelineService.totalAssets})',
|
||||
);
|
||||
return FixedSegmentBuilder(
|
||||
buckets: buckets,
|
||||
tileHeight: tileExtent,
|
||||
|
||||
@@ -21,13 +21,14 @@ import 'package:immich_mobile/presentation/widgets/timeline/scrubber.widget.dart
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline_drag_region.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
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';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
class Timeline extends StatelessWidget {
|
||||
const Timeline({
|
||||
@@ -136,6 +137,7 @@ class _SliverTimeline extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
static final Logger _log = Logger('Timeline');
|
||||
late final ScrollController _scrollController;
|
||||
StreamSubscription? _eventSubscription;
|
||||
|
||||
@@ -153,6 +155,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_log.info('SliverTimeline initState');
|
||||
_scrollController = ScrollController(onAttach: _restoreAssetPosition);
|
||||
_eventSubscription = EventStream.shared.listen(_onEvent);
|
||||
|
||||
@@ -179,12 +182,19 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
}
|
||||
|
||||
void _onEvent(Event event) {
|
||||
_log.info('event ${event.runtimeType}');
|
||||
switch (event) {
|
||||
case ScrollToTopEvent():
|
||||
ref.read(timelineStateProvider.notifier).setScrubbing(true);
|
||||
_scrollController
|
||||
.animateTo(0, duration: const Duration(milliseconds: 250), curve: Curves.easeInOut)
|
||||
.whenComplete(() => ref.read(timelineStateProvider.notifier).setScrubbing(false));
|
||||
{
|
||||
final timelineState = ref.read(timelineStateProvider.notifier);
|
||||
timelineState.setScrubbing(true);
|
||||
_scrollController
|
||||
.animateTo(0, duration: const Duration(milliseconds: 250), curve: Curves.easeInOut)
|
||||
.whenComplete(() {
|
||||
_log.info('ScrollToTop animation done -> setScrubbing(false)');
|
||||
timelineState.setScrubbing(false);
|
||||
});
|
||||
}
|
||||
|
||||
case ScrollToDateEvent scrollToDateEvent:
|
||||
_scrollToDate(scrollToDateEvent.date);
|
||||
@@ -243,12 +253,14 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_log.info('SliverTimeline dispose');
|
||||
_scrollController.dispose();
|
||||
_eventSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _scrollToDate(DateTime date) {
|
||||
final timelineState = ref.read(timelineStateProvider.notifier);
|
||||
final asyncSegments = ref.read(timelineSegmentProvider);
|
||||
asyncSegments.whenData((segments) {
|
||||
// Find the segment that contains assets from the target date
|
||||
@@ -275,16 +287,20 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
if (fallbackSegment != null) {
|
||||
// Scroll to the segment with a small offset to show the header
|
||||
final targetOffset = fallbackSegment.startOffset - 50;
|
||||
ref.read(timelineStateProvider.notifier).setScrubbing(true);
|
||||
timelineState.setScrubbing(true);
|
||||
_scrollController
|
||||
.animateTo(
|
||||
targetOffset.clamp(0.0, _scrollController.position.maxScrollExtent),
|
||||
duration: const Duration(milliseconds: 500),
|
||||
curve: Curves.easeInOut,
|
||||
)
|
||||
.whenComplete(() => ref.read(timelineStateProvider.notifier).setScrubbing(false));
|
||||
.whenComplete(() {
|
||||
_log.info('ScrollToDate animation done -> setScrubbing(false)');
|
||||
timelineState.setScrubbing(false);
|
||||
});
|
||||
} else {
|
||||
ref.read(timelineStateProvider.notifier).setScrubbing(false);
|
||||
_log.info('ScrollToDate: no matching segment for $date -> setScrubbing(false)');
|
||||
timelineState.setScrubbing(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -301,19 +317,19 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
void _stopDrag() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// Update the physics post frame to prevent sudden change in physics on iOS.
|
||||
setState(() {
|
||||
_scrollPhysics = null;
|
||||
});
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_scrollPhysics = null;
|
||||
});
|
||||
}
|
||||
});
|
||||
setState(() {
|
||||
_dragging = false;
|
||||
_draggedAssets.clear();
|
||||
});
|
||||
// Reset the scrolling state after a small delay to allow bottom sheet to expand again
|
||||
final timelineState = ref.read(timelineStateProvider.notifier);
|
||||
Future.delayed(const Duration(milliseconds: 300), () {
|
||||
if (mounted) {
|
||||
ref.read(timelineStateProvider.notifier).setScrolling(false);
|
||||
}
|
||||
timelineState.setScrolling(false);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@ import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart'
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
final _log = Logger('TimelineProvider');
|
||||
|
||||
final timelineRepositoryProvider = Provider<DriftTimelineRepository>(
|
||||
(ref) => DriftTimelineRepository(ref.watch(driftProvider)),
|
||||
@@ -18,7 +21,11 @@ final timelineServiceProvider = Provider<TimelineService>(
|
||||
(ref) {
|
||||
final timelineUsers = ref.watch(timelineUsersProvider).valueOrNull ?? [];
|
||||
final timelineService = ref.watch(timelineFactoryProvider).main(timelineUsers);
|
||||
ref.onDispose(timelineService.dispose);
|
||||
_log.info('main TimelineService built users=$timelineUsers');
|
||||
ref.onDispose(() {
|
||||
_log.info('main TimelineService disposed');
|
||||
timelineService.dispose();
|
||||
});
|
||||
return timelineService;
|
||||
},
|
||||
// Empty dependencies to inform the framework that this provider
|
||||
@@ -36,8 +43,12 @@ final timelineFactoryProvider = Provider<TimelineFactory>(
|
||||
final timelineUsersProvider = StreamProvider<List<String>>((ref) {
|
||||
final currentUserId = ref.watch(currentUserProvider.select((u) => u?.id));
|
||||
if (currentUserId == null) {
|
||||
_log.info('timelineUsers: currentUserId=null -> []');
|
||||
return Stream.value([]);
|
||||
}
|
||||
|
||||
return ref.watch(timelineRepositoryProvider).watchTimelineUserIds(currentUserId);
|
||||
return ref.watch(timelineRepositoryProvider).watchTimelineUserIds(currentUserId).map((users) {
|
||||
_log.info('timelineUsers emission: $users');
|
||||
return users;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -285,7 +285,9 @@ class AssetMediaRepository {
|
||||
return 0;
|
||||
}
|
||||
|
||||
final shareFile = switch (fileType) {
|
||||
final effectiveFileType = asset.isVideo ? ShareAssetType.original : fileType;
|
||||
|
||||
final shareFile = switch (effectiveFileType) {
|
||||
ShareAssetType.original => await _getOriginalShareFile(
|
||||
asset,
|
||||
cancelCompleter: cancelCompleter,
|
||||
|
||||
@@ -16,15 +16,11 @@ class SlideshowSettings extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final slideshow = ref.read(appConfigProvider).slideshow;
|
||||
final useTransition = useState(slideshow.transition);
|
||||
final useRepeat = useState(slideshow.repeat);
|
||||
final useDuration = useState(slideshow.duration);
|
||||
final useLook = useState(slideshow.look);
|
||||
final useDirection = useState(slideshow.direction);
|
||||
|
||||
useValueChanged<bool, void>(useTransition.value, (_, __) {
|
||||
ref.read(settingsProvider).write(.slideshowTransition, useTransition.value);
|
||||
});
|
||||
useValueChanged<bool, void>(useRepeat.value, (_, __) {
|
||||
ref.read(settingsProvider).write(.slideshowRepeat, useRepeat.value);
|
||||
});
|
||||
@@ -45,11 +41,6 @@ class SlideshowSettings extends HookConsumerWidget {
|
||||
title: 'slideshow'.t(context: context),
|
||||
icon: Icons.slideshow_outlined,
|
||||
),
|
||||
SettingsSwitchListTile(
|
||||
valueNotifier: useTransition,
|
||||
title: "show_slideshow_transition".t(context: context),
|
||||
enabled: useDirection.value != SlideshowDirection.shuffle,
|
||||
),
|
||||
SettingsSwitchListTile(
|
||||
valueNotifier: useRepeat,
|
||||
title: "slideshow_repeat".t(context: context),
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ class AssetBulkUpdateDto {
|
||||
///
|
||||
Optional<String?> dateTimeOriginal;
|
||||
|
||||
/// Relative time offset in seconds
|
||||
/// Relative time offset in minutes
|
||||
///
|
||||
/// Minimum value: -9007199254740991
|
||||
/// Maximum value: 9007199254740991
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ class AssetBulkUploadCheckItem {
|
||||
/// Base64 or hex encoded SHA1 hash
|
||||
String checksum;
|
||||
|
||||
/// Asset ID
|
||||
/// Client-side identifier echoed in the response to match results to inputs (e.g. filename)
|
||||
String id;
|
||||
|
||||
@override
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/services/sync_linked_album.service.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/store.provider.dart';
|
||||
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
|
||||
|
||||
import '../../infrastructure/repository.mock.dart';
|
||||
import '../service.mock.dart';
|
||||
|
||||
void main() {
|
||||
// A container with the service's deps overridden but cancellationProvider left
|
||||
// alone, i.e. the root (main) isolate, where cancellationProvider has no
|
||||
// override and throws if read. The UI reads this provider here.
|
||||
ProviderContainer rootContainer() {
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
localAlbumRepository.overrideWithValue(MockLocalAlbumRepository()),
|
||||
remoteAlbumRepository.overrideWithValue(MockRemoteAlbumRepository()),
|
||||
driftAlbumApiRepositoryProvider.overrideWithValue(MockDriftAlbumApiRepository()),
|
||||
storeServiceProvider.overrideWithValue(MockStoreService()),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
return container;
|
||||
}
|
||||
|
||||
// Regression for #29125 (Sync Albums toggle) and #29119 (can't leave the album
|
||||
// selection screen): #28694 made the provider watch cancellationProvider, so
|
||||
// reading it off the isolate threw. The cancellation now lives on the isolate
|
||||
// call path, not the provider, so the UI can build it.
|
||||
test('builds on the root isolate without a cancellationProvider override', () {
|
||||
final container = rootContainer();
|
||||
|
||||
expect(() => container.read(syncLinkedAlbumServiceProvider), returnsNormally);
|
||||
expect(container.read(syncLinkedAlbumServiceProvider), isA<SyncLinkedAlbumService>());
|
||||
});
|
||||
|
||||
test('manageLinkedAlbums runs from the UI without a cancellation signal', () {
|
||||
final service = rootContainer().read(syncLinkedAlbumServiceProvider);
|
||||
|
||||
expect(service.manageLinkedAlbums(const [], 'user-1'), completes);
|
||||
});
|
||||
}
|
||||
@@ -16868,7 +16868,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"dateTimeRelative": {
|
||||
"description": "Relative time offset in seconds",
|
||||
"description": "Relative time offset in minutes",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": -9007199254740991,
|
||||
"type": "integer"
|
||||
@@ -16965,7 +16965,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"description": "Asset ID",
|
||||
"description": "Client-side identifier echoed in the response to match results to inputs (e.g. filename)",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
@@ -18490,7 +18490,6 @@
|
||||
"properties": {
|
||||
"cronExpression": {
|
||||
"description": "Cron expression",
|
||||
"pattern": "(((\\d+,)+\\d+|(\\d+(\\/|-)\\d+)|\\d+|\\*) ?){5,7}",
|
||||
"type": "string"
|
||||
},
|
||||
"enabled": {
|
||||
@@ -25677,7 +25676,6 @@
|
||||
"properties": {
|
||||
"cronExpression": {
|
||||
"description": "Cron expression for when the integrity check should run",
|
||||
"pattern": "(((\\d+,)+\\d+|(\\d+(\\/|-)\\d+)|\\d+|\\*) ?){5,7}",
|
||||
"type": "string"
|
||||
},
|
||||
"enabled": {
|
||||
@@ -25710,7 +25708,6 @@
|
||||
"properties": {
|
||||
"cronExpression": {
|
||||
"description": "Cron expression for when the integrity check should run",
|
||||
"pattern": "(((\\d+,)+\\d+|(\\d+(\\/|-)\\d+)|\\d+|\\*) ?){5,7}",
|
||||
"type": "string"
|
||||
},
|
||||
"enabled": {
|
||||
@@ -25810,7 +25807,6 @@
|
||||
"properties": {
|
||||
"cronExpression": {
|
||||
"description": "Cron expression",
|
||||
"pattern": "(((\\d+,)+\\d+|(\\d+(\\/|-)\\d+)|\\d+|\\*) ?){5,7}",
|
||||
"type": "string"
|
||||
},
|
||||
"enabled": {
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@
|
||||
"pnpm": ">=10.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.12.4",
|
||||
"@types/node": "^24.13.2",
|
||||
"prettier": "^3.8.3",
|
||||
"prettier-plugin-sort-json": "^4.2.0",
|
||||
"semver": "^7.8.1",
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/micromatch": "^4.0.9",
|
||||
"@types/mock-fs": "^4.13.1",
|
||||
"@types/node": "^24.12.4",
|
||||
"@types/node": "^24.13.2",
|
||||
"@vitest/coverage-v8": "^4.0.0",
|
||||
"byte-size": "^9.0.0",
|
||||
"cli-progress": "^3.12.0",
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"devDependencies": {
|
||||
"@extism/js-pdk": "^1.1.1",
|
||||
"@immich/sdk": "workspace:*",
|
||||
"@types/node": "^24.12.4",
|
||||
"@types/node": "^24.13.2",
|
||||
"esbuild": "^0.28.0",
|
||||
"tsc-alias": "^1.8.16",
|
||||
"typescript": "^5.9.3"
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"@oazapfts/runtime": "^1.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.12.4",
|
||||
"@types/node": "^24.13.2",
|
||||
"typescript": "^6.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -673,7 +673,7 @@ export type AssetMediaResponseDto = {
|
||||
export type AssetBulkUpdateDto = {
|
||||
/** Original date and time */
|
||||
dateTimeOriginal?: string;
|
||||
/** Relative time offset in seconds */
|
||||
/** Relative time offset in minutes */
|
||||
dateTimeRelative?: number;
|
||||
/** Asset description */
|
||||
description?: string;
|
||||
@@ -696,7 +696,7 @@ export type AssetBulkUpdateDto = {
|
||||
export type AssetBulkUploadCheckItem = {
|
||||
/** Base64 or hex encoded SHA1 hash */
|
||||
checksum: string;
|
||||
/** Asset ID */
|
||||
/** Client-side identifier echoed in the response to match results to inputs (e.g. filename) */
|
||||
id: string;
|
||||
};
|
||||
export type AssetBulkUploadCheckDto = {
|
||||
|
||||
Generated
+2796
-2719
File diff suppressed because it is too large
Load Diff
@@ -65,3 +65,5 @@ preferWorkspacePackages: true
|
||||
injectWorkspacePackages: true
|
||||
shamefullyHoist: false
|
||||
verifyDepsBeforeRun: install
|
||||
minimumReleaseAgeExclude:
|
||||
- '@immich/ui@0.81.1'
|
||||
|
||||
+1
-1
@@ -138,7 +138,7 @@
|
||||
"@types/luxon": "^3.6.2",
|
||||
"@types/mock-fs": "^4.13.1",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^24.12.4",
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"@types/picomatch": "^4.0.0",
|
||||
"@types/pngjs": "^6.0.5",
|
||||
|
||||
@@ -58,7 +58,7 @@ const AssetMediaCreateSchema = AssetMediaBaseSchema.extend({
|
||||
|
||||
const AssetBulkUploadCheckItemSchema = z
|
||||
.object({
|
||||
id: z.string().describe('Asset ID'),
|
||||
id: z.string().describe('Client-side identifier echoed in the response to match results to inputs (e.g. filename)'),
|
||||
checksum: z.string().describe('Base64 or hex encoded SHA1 hash'),
|
||||
})
|
||||
.meta({ id: 'AssetBulkUploadCheckItem' });
|
||||
|
||||
@@ -41,7 +41,7 @@ const UpdateAssetBaseSchema = z
|
||||
const AssetBulkUpdateBaseSchema = UpdateAssetBaseSchema.extend({
|
||||
ids: z.array(z.uuidv4()).describe('Asset IDs to update'),
|
||||
duplicateId: z.string().nullish().describe('Duplicate ID'),
|
||||
dateTimeRelative: z.int().optional().describe('Relative time offset in seconds'),
|
||||
dateTimeRelative: z.int().optional().describe('Relative time offset in minutes'),
|
||||
timeZone: z.string().optional().describe('Time zone (IANA timezone)'),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { validateCronExpression } from 'cron';
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import { SystemConfig } from 'src/config';
|
||||
import {
|
||||
@@ -43,7 +44,16 @@ const JobSettingsSchema = z
|
||||
|
||||
const cronExpressionSchema = z
|
||||
.string()
|
||||
.regex(/(((\d+,)+\d+|(\d+(\/|-)\d+)|\d+|\*) ?){5,7}/, 'Invalid cron expression')
|
||||
.superRefine((value, ctx) => {
|
||||
const validated = validateCronExpression(value);
|
||||
if (!validated.valid) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: `Invalid cron expression. ${validated.error?.message ?? ''}`,
|
||||
input: value,
|
||||
});
|
||||
}
|
||||
})
|
||||
.describe('Cron expression');
|
||||
|
||||
const DatabaseBackupSchema = z
|
||||
|
||||
@@ -129,10 +129,10 @@ from
|
||||
and "integrity_report"."type" = $1
|
||||
where
|
||||
"asset"."deletedAt" is null
|
||||
and "createdAt" >= $2
|
||||
and "createdAt" <= $3
|
||||
and "integrity_report"."createdAt" >= $2
|
||||
and "integrity_report"."createdAt" <= $3
|
||||
order by
|
||||
"createdAt" asc
|
||||
"integrity_report"."createdAt" asc
|
||||
|
||||
-- IntegrityRepository.streamIntegrityReports
|
||||
select
|
||||
|
||||
@@ -177,9 +177,9 @@ export class IntegrityRepository {
|
||||
'asset.id as assetId',
|
||||
'integrity_report.id as reportId',
|
||||
])
|
||||
.$if(startMarker !== undefined, (qb) => qb.where('createdAt', '>=', startMarker!))
|
||||
.$if(endMarker !== undefined, (qb) => qb.where('createdAt', '<=', endMarker!))
|
||||
.orderBy('createdAt', 'asc')
|
||||
.$if(startMarker !== undefined, (qb) => qb.where('integrity_report.createdAt', '>=', startMarker!))
|
||||
.$if(endMarker !== undefined, (qb) => qb.where('integrity_report.createdAt', '<=', endMarker!))
|
||||
.orderBy('integrity_report.createdAt', 'asc')
|
||||
.stream();
|
||||
}
|
||||
|
||||
|
||||
@@ -78,8 +78,9 @@ export class MapRepository {
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [[DummyValue.UUID], [DummyValue.UUID]] })
|
||||
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID], [DummyValue.UUID]] })
|
||||
getMapMarkers(
|
||||
authUserId: string,
|
||||
ownerIds: string[],
|
||||
albumIds: string[],
|
||||
{ isArchived, isFavorite, fileCreatedAfter, fileCreatedBefore }: MapMarkerSearchOptions = {},
|
||||
@@ -89,7 +90,7 @@ export class MapRepository {
|
||||
qb.where((eb) =>
|
||||
eb.or([
|
||||
eb('asset.visibility', '=', AssetVisibility.Timeline),
|
||||
eb('asset.visibility', '=', AssetVisibility.Archive),
|
||||
eb.and([eb('asset.ownerId', '=', authUserId), eb('asset.visibility', '=', AssetVisibility.Archive)]),
|
||||
]),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -369,6 +369,26 @@ describe(DuplicateService.name, () => {
|
||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.SidecarWrite, data: { id: asset1.id } }]);
|
||||
});
|
||||
|
||||
it('should not merge metadata when multiple assets are kept', async () => {
|
||||
const asset1 = AssetFactory.create({ isFavorite: true });
|
||||
const asset2 = AssetFactory.create();
|
||||
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1']));
|
||||
mocks.duplicateRepository.get.mockResolvedValue({
|
||||
duplicateId: 'group-1',
|
||||
assets: [asset1 as unknown as MapAsset, asset2 as unknown as MapAsset],
|
||||
});
|
||||
|
||||
const result = await sut.resolve(authStub.admin, {
|
||||
groups: [{ duplicateId: 'group-1', keepAssetIds: [asset1.id, asset2.id], trashAssetIds: [] }],
|
||||
});
|
||||
|
||||
expect(result[0].success).toBe(true);
|
||||
expect(mocks.album.addAssetIdsToAlbums).not.toHaveBeenCalled();
|
||||
expect(mocks.tag.replaceAssetTags).not.toHaveBeenCalled();
|
||||
expect(mocks.asset.updateAllExif).not.toHaveBeenCalled();
|
||||
expect(mocks.asset.updateAll).toHaveBeenCalledWith([asset1.id, asset2.id], { duplicateId: null });
|
||||
});
|
||||
|
||||
// NOTE: The following integration-style tests are covered by E2E tests instead
|
||||
// to avoid complex mock setup. The validation and error-handling logic above
|
||||
// is thoroughly unit tested.
|
||||
|
||||
@@ -156,51 +156,51 @@ export class DuplicateService extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
const assetAlbumMap = await this.albumRepository.getByAssetIds(auth.user.id, [...groupAssetIds]);
|
||||
// Only merge metadata into the keeper when exactly one asset can absorb trashed duplicates.
|
||||
if (idsToKeep.length === 1 && idsToTrash.length > 0) {
|
||||
const assetAlbumMap = await this.albumRepository.getByAssetIds(auth.user.id, [...groupAssetIds]);
|
||||
|
||||
const { assetUpdate, exifUpdate, mergedAlbumIds, mergedTagIds, mergedTagValues } = this.getSyncMergeResult(
|
||||
duplicateGroup.assets,
|
||||
assetAlbumMap,
|
||||
);
|
||||
const { assetUpdate, exifUpdate, mergedAlbumIds, mergedTagIds, mergedTagValues } = this.getSyncMergeResult(
|
||||
duplicateGroup.assets,
|
||||
assetAlbumMap,
|
||||
);
|
||||
|
||||
if (mergedAlbumIds.length > 0) {
|
||||
const allowedAlbumIds = await this.checkAccess({
|
||||
auth,
|
||||
permission: Permission.AlbumAssetCreate,
|
||||
ids: mergedAlbumIds,
|
||||
});
|
||||
if (mergedAlbumIds.length > 0) {
|
||||
const allowedAlbumIds = await this.checkAccess({
|
||||
auth,
|
||||
permission: Permission.AlbumAssetCreate,
|
||||
ids: mergedAlbumIds,
|
||||
});
|
||||
|
||||
const allowedShareIds = await this.checkAccess({
|
||||
auth,
|
||||
permission: Permission.AssetShare,
|
||||
ids: idsToKeep,
|
||||
});
|
||||
const allowedShareIds = await this.checkAccess({
|
||||
auth,
|
||||
permission: Permission.AssetShare,
|
||||
ids: idsToKeep,
|
||||
});
|
||||
|
||||
if (allowedAlbumIds.size > 0 && allowedShareIds.size > 0) {
|
||||
await this.albumRepository.addAssetIdsToAlbums(
|
||||
[...allowedAlbumIds].flatMap((albumId) => [...allowedShareIds].map((assetId) => ({ albumId, assetId }))),
|
||||
);
|
||||
if (allowedAlbumIds.size > 0 && allowedShareIds.size > 0) {
|
||||
await this.albumRepository.addAssetIdsToAlbums(
|
||||
[...allowedAlbumIds].flatMap((albumId) => [...allowedShareIds].map((assetId) => ({ albumId, assetId }))),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mergedTagIds.length > 0) {
|
||||
const allowedTagIds = await this.checkAccess({
|
||||
auth,
|
||||
permission: Permission.TagAsset,
|
||||
ids: mergedTagIds,
|
||||
});
|
||||
if (mergedTagIds.length > 0) {
|
||||
const allowedTagIds = await this.checkAccess({
|
||||
auth,
|
||||
permission: Permission.TagAsset,
|
||||
ids: mergedTagIds,
|
||||
});
|
||||
|
||||
if (allowedTagIds.size > 0) {
|
||||
// Replace tags for each keeper asset to ensure all merged tags are applied
|
||||
await Promise.all(idsToKeep.map((assetId) => this.tagRepository.replaceAssetTags(assetId, [...allowedTagIds])));
|
||||
if (allowedTagIds.size > 0) {
|
||||
await Promise.all(
|
||||
idsToKeep.map((assetId) => this.tagRepository.replaceAssetTags(assetId, [...allowedTagIds])),
|
||||
);
|
||||
|
||||
// Update asset_exif.tags so the subsequent SidecarWrite + MetadataExtraction
|
||||
// cycle preserves the merged tags (updateAllExif locks the property automatically)
|
||||
await this.assetRepository.updateAllExif(idsToKeep, { tags: mergedTagValues });
|
||||
await this.assetRepository.updateAllExif(idsToKeep, { tags: mergedTagValues });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (idsToKeep.length > 0) {
|
||||
const hasExifUpdate = Object.keys(exifUpdate).length > 0;
|
||||
const hasTagUpdate = mergedTagIds.length > 0;
|
||||
|
||||
@@ -213,6 +213,8 @@ export class DuplicateService extends BaseService {
|
||||
}
|
||||
|
||||
await this.assetRepository.updateAll(idsToKeep, { duplicateId: null, ...assetUpdate });
|
||||
} else if (idsToKeep.length > 0) {
|
||||
await this.assetRepository.updateAll(idsToKeep, { duplicateId: null });
|
||||
}
|
||||
|
||||
if (idsToTrash.length > 0) {
|
||||
|
||||
@@ -59,6 +59,7 @@ describe(MapService.name, () => {
|
||||
const markers = await sut.getMapMarkers(auth, { withPartners: true });
|
||||
|
||||
expect(mocks.map.getMapMarkers).toHaveBeenCalledWith(
|
||||
auth.user.id,
|
||||
[auth.user.id, partner.sharedById],
|
||||
expect.arrayContaining([]),
|
||||
{ withPartners: true },
|
||||
|
||||
@@ -15,7 +15,7 @@ export class MapService extends BaseService {
|
||||
|
||||
const albumIds = options.withSharedAlbums ? await this.albumRepository.getAllIds(auth.user.id) : [];
|
||||
|
||||
return this.mapRepository.getMapMarkers(userIds, albumIds, options);
|
||||
return this.mapRepository.getMapMarkers(auth.user.id, userIds, albumIds, options);
|
||||
}
|
||||
|
||||
async reverseGeocode(dto: MapReverseGeocodeDto) {
|
||||
|
||||
@@ -319,14 +319,14 @@ describe(SystemConfigService.name, () => {
|
||||
it('should accept valid cron expressions', async () => {
|
||||
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
|
||||
mocks.systemMetadata.readFile.mockResolvedValue(
|
||||
JSON.stringify({ library: { scan: { cronExpression: '0 0 * * *' } } }),
|
||||
JSON.stringify({ library: { scan: { cronExpression: '0 0 */3 * *' } } }),
|
||||
);
|
||||
|
||||
await expect(sut.getSystemConfig()).resolves.toMatchObject({
|
||||
library: {
|
||||
scan: {
|
||||
enabled: true,
|
||||
cronExpression: '0 0 * * *',
|
||||
cronExpression: '0 0 */3 * *',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@
|
||||
"@formatjs/icu-messageformat-parser": "^3.0.0",
|
||||
"@immich/justified-layout-wasm": "^0.4.3",
|
||||
"@immich/sdk": "workspace:*",
|
||||
"@immich/ui": "^0.80.0",
|
||||
"@immich/ui": "^0.81.1",
|
||||
"@mapbox/mapbox-gl-rtl-text": "0.4.0",
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@noble/hashes": "^2.2.0",
|
||||
|
||||
@@ -261,10 +261,6 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if show.brokenAsset}
|
||||
<BrokenAsset class="absolute size-full text-xl" />
|
||||
{/if}
|
||||
|
||||
{#if show.preview}
|
||||
<ImageLayer
|
||||
{adaptiveImageLoader}
|
||||
@@ -290,6 +286,10 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if show.brokenAsset}
|
||||
<BrokenAsset class="absolute inset-0 z-10 size-full text-xl" />
|
||||
{/if}
|
||||
|
||||
{#if overlays}
|
||||
<div class="pointer-events-none absolute inset-0">
|
||||
{@render overlays()}
|
||||
|
||||
@@ -12,10 +12,11 @@
|
||||
icon?: string;
|
||||
title: string;
|
||||
valuePromise: MaybePromise<ValueData>;
|
||||
tooltip?: string;
|
||||
footer?: Snippet;
|
||||
}
|
||||
|
||||
let { icon, title, valuePromise, footer }: Props = $props();
|
||||
let { icon, title, valuePromise, tooltip, footer }: Props = $props();
|
||||
const zeros = (data?: ValueData) => {
|
||||
let length = 13;
|
||||
if (data) {
|
||||
@@ -32,7 +33,7 @@
|
||||
{#if icon}
|
||||
<Icon {icon} size="40" />
|
||||
{/if}
|
||||
<Text size="giant" fontWeight="medium">{title}</Text>
|
||||
<Text size="giant" fontWeight="medium" title={tooltip}>{title}</Text>
|
||||
</div>
|
||||
|
||||
{#await valuePromise}
|
||||
|
||||
@@ -208,13 +208,13 @@
|
||||
if (relativeDate) {
|
||||
const duration = Duration.fromISO(relativeDate);
|
||||
return {
|
||||
fileCreatedAfter: duration.isValid ? DateTime.now().minus(duration).toISO() : undefined,
|
||||
fileCreatedAfter: duration.isValid ? DateTime.now().minus(duration).toUTC().toISO() : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
fileCreatedAfter: dateAfter?.toUTC().toISO(),
|
||||
fileCreatedBefore: dateBefore?.toUTC().toISO(),
|
||||
fileCreatedAfter: dateAfter,
|
||||
fileCreatedBefore: dateBefore,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -243,7 +243,7 @@
|
||||
}
|
||||
|
||||
const handleSettingsClick = async () => {
|
||||
const settings = await modalManager.show(MapSettingsModal, { settings: { ...$mapSettings } });
|
||||
const settings = await modalManager.show(MapSettingsModal);
|
||||
if (settings) {
|
||||
const shouldUpdate = !isEqual(omit(settings, 'allowDarkMode'), omit($mapSettings, 'allowDarkMode'));
|
||||
$mapSettings = settings;
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
import { navigate } from '$lib/utils/navigation';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { type AlbumResponseDto, type AssetResponseDto, type PersonResponseDto, getAssetInfo } from '@immich/sdk';
|
||||
import { onDestroy, onMount, untrack } from 'svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
@@ -82,7 +82,7 @@
|
||||
$effect(() => {
|
||||
const asset = assetViewerManager.asset;
|
||||
if (asset) {
|
||||
untrack(() => handlePromiseError(loadCloseAssets(asset)));
|
||||
handlePromiseError(loadCloseAssets(asset));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
rounded?: boolean | 'full';
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { rounded = true, children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
class="inline-block h-min bg-primary px-3 py-1 text-center align-baseline text-xs leading-none whitespace-nowrap text-subtle"
|
||||
class:rounded-md={rounded === true}
|
||||
class:rounded-full={rounded === 'full'}
|
||||
>
|
||||
{@render children?.()}
|
||||
</span>
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import type { SearchOptions } from '$lib/utils/dipatch';
|
||||
import { IconButton, LoadingSpinner } from '@immich/ui';
|
||||
import { mdiClose, mdiMagnify } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
@@ -9,7 +8,7 @@
|
||||
roundedBottom?: boolean;
|
||||
showLoadingSpinner: boolean;
|
||||
placeholder: string;
|
||||
onSearch?: (options: SearchOptions) => void;
|
||||
onSearch?: (options: { force?: boolean }) => void;
|
||||
onReset?: () => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ class AssetViewerManager extends BaseEventManager<Events> {
|
||||
imageLoaderStatus = $state<ImageLoaderStatus | undefined>();
|
||||
#isImageLoading = $derived.by(() => {
|
||||
const quality = this.imageLoaderStatus?.quality;
|
||||
if (!quality) {
|
||||
if (!quality || this.imageLoaderStatus?.hasError) {
|
||||
return false;
|
||||
}
|
||||
const previewOrOriginalReady = quality.preview === 'success' || quality.original === 'success';
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
<script lang="ts">
|
||||
import type { MapSettings } from '$lib/stores/preferences.store';
|
||||
import { mapSettings, type MapSettings } from '$lib/stores/preferences.store';
|
||||
import { Button, DatePicker, Field, FormModal, Select, Stack, Switch } from '@immich/ui';
|
||||
import { Duration } from 'luxon';
|
||||
import { DateTime, Duration } from 'luxon';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fly } from 'svelte/transition';
|
||||
|
||||
type Props = {
|
||||
settings: MapSettings;
|
||||
onClose: (settings?: MapSettings) => void;
|
||||
};
|
||||
|
||||
let { settings: initialValues, onClose }: Props = $props();
|
||||
let settings = $state(initialValues);
|
||||
let { onClose }: Props = $props();
|
||||
let settings = $state({ ...$mapSettings });
|
||||
|
||||
let customDateRange = $state(!!settings.dateAfter || !!settings.dateBefore);
|
||||
|
||||
@@ -41,10 +40,17 @@
|
||||
{#if customDateRange}
|
||||
<div in:fly={{ y: 10, duration: 200 }} class="flex flex-col gap-4">
|
||||
<Field label={$t('date_after')}>
|
||||
<DatePicker bind:value={settings.dateAfter} maxDate={settings.dateBefore} />
|
||||
<DatePicker
|
||||
value={DateTime.fromISO(settings.dateAfter ?? '')}
|
||||
maxDate={DateTime.fromISO(settings.dateBefore ?? '')}
|
||||
onChange={(date) => (settings.dateAfter = date?.toUTC().toISO() ?? undefined)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={$t('date_before')}>
|
||||
<DatePicker bind:value={settings.dateBefore} />
|
||||
<DatePicker
|
||||
value={DateTime.fromISO(settings.dateBefore ?? '')}
|
||||
onChange={(date) => (settings.dateBefore = date?.toUTC().toISO() ?? undefined)}
|
||||
/>
|
||||
</Field>
|
||||
<div class="flex justify-center">
|
||||
<Button
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import { MediaType, QueryType, validQueryTypes } from '$lib/constants';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import type { SearchFilter } from '$lib/types';
|
||||
import { parseUtcDate } from '$lib/utils/date-time';
|
||||
import { asLocalTimeISO, parseUtcDate } from '$lib/utils/date-time';
|
||||
import { generateId } from '$lib/utils/generate-id';
|
||||
import { AssetTypeEnum, AssetVisibility, type MetadataSearchDto, type SmartSearchDto } from '@immich/sdk';
|
||||
import { Button, HStack, Modal, ModalBody, ModalFooter } from '@immich/ui';
|
||||
@@ -27,7 +27,6 @@
|
||||
|
||||
let { searchQuery, onClose }: Props = $props();
|
||||
|
||||
const parseOptionalDate = (dateString?: DateTime) => (dateString ? parseUtcDate(dateString.toString()) : undefined);
|
||||
const toStartOfDayDate = (dateString: string) => parseUtcDate(dateString)?.startOf('day') || undefined;
|
||||
const formId = generateId();
|
||||
|
||||
@@ -144,8 +143,12 @@
|
||||
make: filter.camera.make,
|
||||
model: filter.camera.model,
|
||||
lensModel: filter.camera.lensModel,
|
||||
takenAfter: parseOptionalDate(filter.date.takenAfter)?.startOf('day').toISO() || undefined,
|
||||
takenBefore: parseOptionalDate(filter.date.takenBefore)?.endOf('day').toISO() || undefined,
|
||||
takenAfter: filter.date.takenAfter
|
||||
? asLocalTimeISO(filter.date.takenAfter.startOf('day') as DateTime<true>)
|
||||
: undefined,
|
||||
takenBefore: filter.date.takenBefore
|
||||
? asLocalTimeISO(filter.date.takenBefore.endOf('day') as DateTime<true>)
|
||||
: undefined,
|
||||
visibility: filter.display.isArchive ? AssetVisibility.Archive : undefined,
|
||||
isFavorite: filter.display.isFavorite || undefined,
|
||||
isNotInAlbum: filter.display.isNotInAlbum || undefined,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { DateTime } from 'luxon';
|
||||
import { persisted } from 'svelte-persisted-store';
|
||||
import { browser } from '$app/environment';
|
||||
import { defaultLang } from '$lib/constants';
|
||||
@@ -27,8 +26,8 @@ export interface MapSettings {
|
||||
withPartners: boolean;
|
||||
withSharedAlbums: boolean;
|
||||
relativeDate: string;
|
||||
dateAfter?: DateTime<true>;
|
||||
dateBefore?: DateTime<true>;
|
||||
dateAfter?: string;
|
||||
dateBefore?: string;
|
||||
}
|
||||
|
||||
const defaultMapSettings = {
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
export interface ResetOptions {
|
||||
default?: boolean;
|
||||
}
|
||||
|
||||
export interface SearchOptions {
|
||||
force?: boolean;
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import type { AssetResponseDto } from '@immich/sdk';
|
||||
import { getExifCount } from '$lib/utils/exif-utils';
|
||||
|
||||
describe('getting the exif count', () => {
|
||||
it('returns 0 when exifInfo is undefined', () => {
|
||||
const asset = {};
|
||||
expect(getExifCount(asset as AssetResponseDto)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 0 when exifInfo is empty', () => {
|
||||
const asset = { exifInfo: {} };
|
||||
expect(getExifCount(asset as AssetResponseDto)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns the correct count of non-null exifInfo properties', () => {
|
||||
const asset = { exifInfo: { fileSizeInByte: 200, rating: 5, fNumber: null } };
|
||||
expect(getExifCount(asset as AssetResponseDto)).toBe(2);
|
||||
});
|
||||
|
||||
it('ignores null, undefined and empty properties in exifInfo', () => {
|
||||
const asset = { exifInfo: { fileSizeInByte: 200, rating: null, fNumber: undefined, description: '' } };
|
||||
expect(getExifCount(asset as AssetResponseDto)).toBe(1);
|
||||
});
|
||||
|
||||
it('returns the correct count when all exifInfo properties are non-null', () => {
|
||||
const asset = { exifInfo: { fileSizeInByte: 200, rating: 5, fNumber: 1, description: 'test' } };
|
||||
expect(getExifCount(asset as AssetResponseDto)).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
import type { AssetResponseDto } from '@immich/sdk';
|
||||
|
||||
export const getExifCount = (asset: AssetResponseDto) => {
|
||||
return Object.values(asset.exifInfo ?? {}).filter(Boolean).length;
|
||||
};
|
||||
@@ -116,6 +116,7 @@ const nonIntlNames: Record<string, string> = {
|
||||
kxm: 'Khmer Surin',
|
||||
mfa: 'Malay (Pattani)',
|
||||
swg: 'Schwäbisch',
|
||||
tl: 'Tagalog',
|
||||
};
|
||||
|
||||
const getLanguageName = (code: string) =>
|
||||
|
||||
@@ -5,13 +5,3 @@ export const removeAccents = (str: string) => {
|
||||
export const normalizeSearchString = (str: string) => {
|
||||
return removeAccents(str.toLocaleLowerCase());
|
||||
};
|
||||
|
||||
export const buildDateString = (year: number, month?: number, day?: number) => {
|
||||
return [
|
||||
year.toString(),
|
||||
month && !Number.isNaN(month) ? month.toString() : undefined,
|
||||
day && !Number.isNaN(day) ? day.toString() : undefined,
|
||||
]
|
||||
.filter((date) => date !== undefined)
|
||||
.join('-');
|
||||
};
|
||||
|
||||
@@ -30,9 +30,6 @@ export const getTriggerDescription = ($t: MessageFormatter, type: WorkflowTrigge
|
||||
case WorkflowTrigger.AssetMetadataExtraction: {
|
||||
return $t('trigger_asset_metadata_extraction_description');
|
||||
}
|
||||
default: {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -79,10 +76,6 @@ export const getWorkflowDefaultConfig = (schema: JSONSchemaProperty) => {
|
||||
config[key] = property.properties ? getWorkflowDefaultConfig(property) : {};
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
console.log(`Unknown configuration type: ${property.type}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+10
-3
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { lang, locale } from '$lib/stores/preferences.store';
|
||||
import { getAssetMediaUrl } from '$lib/utils';
|
||||
import { getAllMetadataItems, type DifferingMetadataFields } from '$lib/utils/duplicate-utils';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
@@ -30,7 +30,8 @@
|
||||
initialVisibleCount = 5,
|
||||
}: Props = $props();
|
||||
|
||||
let isFromExternalLibrary = $derived(!!asset.libraryId);
|
||||
const listFormat = $derived(new Intl.ListFormat($lang));
|
||||
const isFromExternalLibrary = $derived(!!asset.libraryId);
|
||||
|
||||
const visibleMetadataItems = $derived(
|
||||
getAllMetadataItems(asset, $t, $locale)
|
||||
@@ -116,7 +117,13 @@
|
||||
{#await getAllAlbums({ assetId: asset.id })}
|
||||
{$t('scanning_for_album')}
|
||||
{:then albums}
|
||||
{$t('in_albums', { values: { count: albums.length } })}
|
||||
{#if albums.length === 1}
|
||||
{albums[0].albumName}
|
||||
{:else}
|
||||
<span title={listFormat.format(albums.map(({ albumName }) => albumName))}>
|
||||
{$t('in_albums', { values: { count: albums.length } })}
|
||||
</span>
|
||||
{/if}
|
||||
{/await}
|
||||
</InfoRow>
|
||||
</div>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
}
|
||||
uploadAssetsStore.reset();
|
||||
}}
|
||||
class="fixed inset-e-16 bottom-6"
|
||||
class="fixed inset-e-16 bottom-6 z-60"
|
||||
>
|
||||
{#if showDetail}
|
||||
<div
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { t, type Translations } from 'svelte-i18n';
|
||||
import type { PageData } from './$types';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
type Props = {
|
||||
data: PageData;
|
||||
@@ -50,7 +51,7 @@
|
||||
};
|
||||
|
||||
let jobs: QueuesResponseLegacyDto | undefined = $state();
|
||||
let expectingUpdate: boolean = $state(false);
|
||||
const activeJobs = new SvelteSet<ManualJobName>();
|
||||
|
||||
const getReportTypeTranslation = (report: IntegrityReport): Translations => {
|
||||
switch (report) {
|
||||
@@ -66,13 +67,27 @@
|
||||
}
|
||||
};
|
||||
|
||||
const getReportTypeDescriptionKey = (report: IntegrityReport): Translations => {
|
||||
switch (report) {
|
||||
case IntegrityReport.UntrackedFile: {
|
||||
return 'admin.maintenance_integrity_untracked_file_description';
|
||||
}
|
||||
case IntegrityReport.MissingFile: {
|
||||
return 'admin.maintenance_integrity_missing_file_description';
|
||||
}
|
||||
case IntegrityReport.ChecksumMismatch: {
|
||||
return 'admin.maintenance_integrity_checksum_mismatch_description';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const updateReports = async () => {
|
||||
jobs = await getQueuesLegacy();
|
||||
if (jobs.integrityCheck.queueStatus.isActive) {
|
||||
expectingUpdate = true;
|
||||
} else if (expectingUpdate) {
|
||||
activeJobs.add(ManualJobName.IntegrityUntrackedFilesRefresh);
|
||||
} else if (activeJobs.size > 0) {
|
||||
activeJobs.clear();
|
||||
integrityReport = await getIntegrityReportSummary();
|
||||
expectingUpdate = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -84,7 +99,7 @@
|
||||
|
||||
const onJobCreate = ({ dto }: { dto: JobCreateDto }) => {
|
||||
if ((Object.values(jobNames).includes(dto.name) || Object.values(refreshJobNames).includes(dto.name)) && jobs) {
|
||||
expectingUpdate = true;
|
||||
activeJobs.add(dto.name);
|
||||
jobs.integrityCheck.queueStatus.isActive = true;
|
||||
}
|
||||
};
|
||||
@@ -106,7 +121,7 @@
|
||||
}
|
||||
}}
|
||||
class="mt-1 self-end"
|
||||
disabled={expectingUpdate}>{$t('admin.maintenance_integrity_check_all')}</Button
|
||||
disabled={activeJobs.size > 0}>{$t('admin.maintenance_integrity_check_all')}</Button
|
||||
>
|
||||
<Button
|
||||
size="tiny"
|
||||
@@ -117,7 +132,7 @@
|
||||
}
|
||||
}}
|
||||
class="mt-1 self-end"
|
||||
disabled={expectingUpdate}>{$t('refresh')}</Button
|
||||
disabled={activeJobs.size > 0}>{$t('refresh')}</Button
|
||||
></HStack
|
||||
>
|
||||
|
||||
@@ -125,36 +140,36 @@
|
||||
{#each reportTypes as reportType (reportType)}
|
||||
<ServerStatisticsCard
|
||||
title={$t(getReportTypeTranslation(reportType))}
|
||||
tooltip={$t(getReportTypeDescriptionKey(reportType))}
|
||||
valuePromise={{ value: integrityReport[reportType] }}
|
||||
>
|
||||
{#snippet footer()}
|
||||
<HStack gap={1} class="justify-end">
|
||||
<Button
|
||||
onclick={() =>
|
||||
handleCreateJob({
|
||||
name: jobNames[reportType],
|
||||
})}
|
||||
size="tiny"
|
||||
variant="ghost"
|
||||
class="mt-1 self-end"
|
||||
disabled={expectingUpdate}>{$t('admin.maintenance_integrity_check_all')}</Button
|
||||
>
|
||||
<Button
|
||||
onclick={() =>
|
||||
handleCreateJob({
|
||||
name: refreshJobNames[reportType],
|
||||
})}
|
||||
size="tiny"
|
||||
variant="ghost"
|
||||
class="mt-1 self-end"
|
||||
disabled={expectingUpdate}>{$t('refresh')}</Button
|
||||
>
|
||||
<HStack gap={1} class="justify-between">
|
||||
<HStack gap={0}>
|
||||
<Button
|
||||
onclick={() =>
|
||||
handleCreateJob({
|
||||
name: jobNames[reportType],
|
||||
})}
|
||||
size="tiny"
|
||||
variant="ghost"
|
||||
disabled={activeJobs.has(jobNames[reportType])}>{$t('admin.maintenance_integrity_check')}</Button
|
||||
>
|
||||
<Button
|
||||
onclick={() =>
|
||||
handleCreateJob({
|
||||
name: refreshJobNames[reportType],
|
||||
})}
|
||||
size="tiny"
|
||||
variant="ghost"
|
||||
disabled={activeJobs.has(refreshJobNames[reportType])}>{$t('refresh')}</Button
|
||||
>
|
||||
</HStack>
|
||||
<Button
|
||||
href={`${Route.systemMaintenanceIntegrityReport({
|
||||
reportType,
|
||||
})}`}
|
||||
size="tiny"
|
||||
class="mt-1 self-end">{$t('view')}</Button
|
||||
size="tiny">{$t('view')}</Button
|
||||
>
|
||||
</HStack>
|
||||
{/snippet}
|
||||
@@ -166,7 +181,7 @@
|
||||
|
||||
<section id="setting-content" class="flex place-content-center sm:mx-4">
|
||||
<section class="w-full pb-28 sm:w-5/6 md:w-212.5">
|
||||
<Text size="small">{$t('admin.maintenance_settings')}</Text>
|
||||
<Text size="small">{$t('admin.maintenance_backup_management')}</Text>
|
||||
|
||||
<SettingAccordion
|
||||
title={$t('admin.maintenance_restore_database_backup')}
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
import { cleanClass } from '$lib';
|
||||
import QueueCardBadge from './QueueCardBadge.svelte';
|
||||
import QueueCardButton from './QueueCardButton.svelte';
|
||||
import Badge from '$lib/elements/Badge.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { asQueueItem } from '$lib/services/queue.service';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { transformToTitleCase } from '$lib/utils';
|
||||
import { QueueCommand, type QueueCommandDto, type QueueResponseDto } from '@immich/sdk';
|
||||
import { Icon, IconButton, Link } from '@immich/ui';
|
||||
import { Badge, Icon, IconButton, Link } from '@immich/ui';
|
||||
import {
|
||||
mdiAlertCircle,
|
||||
mdiAllInclusive,
|
||||
@@ -67,27 +66,16 @@
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
{#if statistics.failed > 0}
|
||||
<Badge>
|
||||
<div class="flex flex-row gap-1">
|
||||
<span class="text-sm">
|
||||
{$t('admin.jobs_failed', { values: { jobCount: statistics.failed.toLocaleString($locale) } })}
|
||||
</span>
|
||||
<IconButton
|
||||
color="primary"
|
||||
icon={mdiClose}
|
||||
aria-label={$t('clear_message')}
|
||||
size="tiny"
|
||||
shape="round"
|
||||
onclick={() => onCommand({ command: QueueCommand.ClearFailed, force: false })}
|
||||
/>
|
||||
</div>
|
||||
<Badge
|
||||
onClose={() => onCommand({ command: QueueCommand.ClearFailed, force: false })}
|
||||
translations={{ close: $t('clear_message') }}
|
||||
>
|
||||
{$t('admin.jobs_failed', { values: { jobCount: statistics.failed.toLocaleString($locale) } })}
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if statistics.delayed > 0}
|
||||
<Badge>
|
||||
<span class="text-sm">
|
||||
{$t('admin.jobs_delayed', { values: { jobCount: statistics.delayed.toLocaleString($locale) } })}
|
||||
</span>
|
||||
{$t('admin.jobs_delayed', { values: { jobCount: statistics.delayed.toLocaleString($locale) } })}
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -88,6 +88,7 @@
|
||||
desc={$t('admin.transcoding_accepted_video_codecs_description')}
|
||||
bind:value={configToEdit.ffmpeg.acceptedVideoCodecs}
|
||||
name="videoCodecs"
|
||||
lockedOptions={[configToEdit.ffmpeg.targetVideoCodec]}
|
||||
options={[
|
||||
{ value: VideoCodec.H264, text: 'H.264' },
|
||||
{ value: VideoCodec.Hevc, text: 'HEVC' },
|
||||
@@ -106,6 +107,7 @@
|
||||
desc={$t('admin.transcoding_accepted_audio_codecs_description')}
|
||||
bind:value={configToEdit.ffmpeg.acceptedAudioCodecs}
|
||||
name="audioCodecs"
|
||||
lockedOptions={[configToEdit.ffmpeg.targetAudioCodec]}
|
||||
options={[
|
||||
{ value: AudioCodec.Aac, text: 'AAC' },
|
||||
{ value: AudioCodec.Mp3, text: 'MP3' },
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
<script lang="ts">
|
||||
<script lang="ts" generics="T extends string">
|
||||
import { Checkbox, Label } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
import { fly } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
value: string[];
|
||||
options: { value: string; text: string }[];
|
||||
value: T[];
|
||||
options: { value: T; text: string }[];
|
||||
label?: string;
|
||||
desc?: string;
|
||||
name?: string;
|
||||
isEdited?: boolean;
|
||||
disabled?: boolean;
|
||||
lockedOptions?: T[];
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -22,9 +23,10 @@
|
||||
name = '',
|
||||
isEdited = false,
|
||||
disabled = false,
|
||||
lockedOptions = [],
|
||||
}: Props = $props();
|
||||
|
||||
function handleCheckboxChange(option: string) {
|
||||
function handleCheckboxChange(option: T) {
|
||||
value = value.includes(option) ? value.filter((item) => item !== option) : [...value, option];
|
||||
}
|
||||
</script>
|
||||
@@ -57,7 +59,7 @@
|
||||
size="tiny"
|
||||
id="{option.value}-checkbox"
|
||||
checked={value.includes(option.value)}
|
||||
{disabled}
|
||||
disabled={disabled || lockedOptions.includes(option.value)}
|
||||
onCheckedChange={() => handleCheckboxChange(option.value)}
|
||||
/>
|
||||
<Label label={option.text} for="{option.value}-checkbox" size="small" />
|
||||
|
||||
@@ -141,11 +141,11 @@
|
||||
<Alert color="danger" title={errorMessage} closable />
|
||||
{/if}
|
||||
|
||||
<Field label={$t('email')}>
|
||||
<Field label={$t('email')} required="indicator">
|
||||
<Input id="email" name="email" type="email" autocomplete="email" bind:value={email} />
|
||||
</Field>
|
||||
|
||||
<Field label={$t('password')}>
|
||||
<Field label={$t('password')} required="indicator">
|
||||
<PasswordInput id="password" bind:value={password} autocomplete="current-password" />
|
||||
</Field>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user