Compare commits

..

1 Commits

Author SHA1 Message Date
Mees Frensel c1c1fa0b0a fix(web): always initialize language manager to set lang and dir 2026-07-07 12:24:49 +02:00
52 changed files with 362 additions and 15079 deletions
+4 -4
View File
@@ -78,10 +78,10 @@ jobs:
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
if: ${{ !github.event.pull_request.head.repo.fork }} if: ${{ !github.event.pull_request.head.repo.fork }}
with: with:
registry: ghcr.io registry: ghcr.io
@@ -96,7 +96,7 @@ jobs:
- name: Generate docker image tags - name: Generate docker image tags
id: metadata id: metadata
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with: with:
flavor: | flavor: |
latest=false latest=false
@@ -107,7 +107,7 @@ jobs:
type=raw,value=latest,enable=${{ github.event_name == 'release' && !github.event.release.prerelease }} type=raw,value=latest,enable=${{ github.event_name == 'release' && !github.event.release.prerelease }}
- name: Build and push image - name: Build and push image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with: with:
file: packages/cli/Dockerfile file: packages/cli/Dockerfile
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
+3 -3
View File
@@ -57,7 +57,7 @@ jobs:
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3 uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file. # If you wish to specify custom queries, you can do so here or in a config file.
@@ -70,7 +70,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below) # If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3 uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
# ️ Command-line programs to run using the OS shell. # ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -83,6 +83,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh # ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3 uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with: with:
category: '/language:${{matrix.language}}' category: '/language:${{matrix.language}}'
+2 -2
View File
@@ -60,7 +60,7 @@ jobs:
suffix: ['', '-cuda', '-rocm', '-openvino', '-armnn', '-rknn'] suffix: ['', '-cuda', '-rocm', '-openvino', '-armnn', '-rknn']
steps: steps:
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -90,7 +90,7 @@ jobs:
suffix: [''] suffix: ['']
steps: steps:
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
+3 -3
View File
@@ -30,7 +30,7 @@ This environment includes the services below. Additional details are available i
- Redis - Redis
- PostgreSQL development database with exposed port `5432` so you can use any database client to access it - PostgreSQL development database with exposed port `5432` so you can use any database client to access it
All the services are packaged to run with a single Docker Compose command. All the services are packaged to run as with single Docker Compose command.
:::tip mise :::tip mise
[mise](https://mise.jdx.dev) is used throughout the project to manage tool versions and run tasks. [Install mise](https://mise.jdx.dev/installing-mise.html), then from the repo root run `mise trust` and `mise install` to get all required tools. Tasks for each service can be run from the repo root using `mise //namespace:task` (e.g. `mise //server:lint`). To list all available tasks, run `mise tasks ls --all`. [mise](https://mise.jdx.dev) is used throughout the project to manage tool versions and run tasks. [Install mise](https://mise.jdx.dev/installing-mise.html), then from the repo root run `mise trust` and `mise install` to get all required tools. Tasks for each service can be run from the repo root using `mise //namespace:task` (e.g. `mise //server:lint`). To list all available tasks, run `mise tasks ls --all`.
@@ -41,7 +41,7 @@ All the services are packaged to run with a single Docker Compose command.
1. Clone the project repo. 1. Clone the project repo.
2. Run `cp docker/example.env docker/.env`. 2. Run `cp docker/example.env docker/.env`.
3. Edit `docker/.env` to provide values for the required variable `UPLOAD_LOCATION`. 3. Edit `docker/.env` to provide values for the required variable `UPLOAD_LOCATION`.
4. Install dependencies - `mise x -- pnpm i` 4. Install dependencies - `pnpm i`
5. From the root directory, run: 5. From the root directory, run:
```bash title="Start development server" ```bash title="Start development server"
@@ -52,7 +52,7 @@ mise dev
All the services will be started with hot-reloading enabled for a quick feedback loop. All the services will be started with hot-reloading enabled for a quick feedback loop.
You can access the web from `http://your-machine-ip:3000` or `http://localhost:3000` and access the server from the mobile app at `http://your-machine-ip:3000` You can access the web from `http://your-machine-ip:3000` or `http://localhost:3000` and access the server from the mobile app at `http://your-machine-ip:3000/api`
**Notes:** **Notes:**
+1 -1
View File
@@ -432,7 +432,7 @@
"transcoding_realtime_enabled": "Enable real-time transcoding", "transcoding_realtime_enabled": "Enable real-time transcoding",
"transcoding_realtime_enabled_description": "If disabled, the server will refuse to start new real-time transcoding sessions.", "transcoding_realtime_enabled_description": "If disabled, the server will refuse to start new real-time transcoding sessions.",
"transcoding_realtime_resolutions": "Resolutions", "transcoding_realtime_resolutions": "Resolutions",
"transcoding_realtime_resolutions_description": "The resolutions offered for real-time transcoding. Higher resolutions may cause playback issues if the server cannot transcode them quickly enough.", "transcoding_realtime_resolutions_description": "The resolutions offered for real-time transcoding. A variant is only offered when its resolution is no larger than the source. Higher resolutions may cause playback issues if the server cannot transcode them quickly enough.",
"transcoding_realtime_video_codecs": "Video codecs", "transcoding_realtime_video_codecs": "Video codecs",
"transcoding_realtime_video_codecs_description": "The video codecs offered for real-time transcoding. Clients will choose the best option they support during playback. AV1 is more efficient than HEVC, which is more efficient than H.264. When using hardware acceleration, only select the codecs the accelerator can encode. When using software transcoding, note that H.264 is faster than AV1, which is faster than HEVC.", "transcoding_realtime_video_codecs_description": "The video codecs offered for real-time transcoding. Clients will choose the best option they support during playback. AV1 is more efficient than HEVC, which is more efficient than H.264. When using hardware acceleration, only select the codecs the accelerator can encode. When using software transcoding, note that H.264 is faster than AV1, which is faster than HEVC.",
"transcoding_reference_frames": "Reference frames", "transcoding_reference_frames": "Reference frames",
@@ -107,67 +107,3 @@ Java_app_alextran_immich_NativeImage_rotate(
} }
return (jlong) dst; return (jlong) dst;
} }
// Convert an RGBA_1010102 buffer to densely-packed RGBA_8888, matching Skia's
// Bitmap.copy(ARGB_8888) byte-for-byte so it's a drop-in for the intermediate 8888 bitmap.
// Each source pixel is a native (little-endian on every Android ABI) u32 with R in bits 0-9,
// G in 10-19, B in 20-29, A in 30-31 (standard RGB10_A2). Each 10-bit channel maps to 8-bit via
// round(v*255/1023). The 2-bit alpha maps to a*85. Both are kept as plain arithmetic as the whole
// loop auto-vectorizes to NEON and measures faster than a LUT on-device. Output is R,G,B,A bytes
// per pixel, i.e. Android ARGB_8888 memory == Dart PixelFormat.rgba8888.
static void convert_1010102(const uint8_t *src, int srcStride, uint32_t *dst, int w, int h) {
for (int y = 0; y < h; y++) {
const uint32_t *srcRow = (const uint32_t *) (src + (size_t) y * srcStride);
uint32_t *dstRow = dst + (size_t) y * w;
for (int x = 0; x < w; x++) {
uint32_t px = srcRow[x];
uint32_t r = ((px & 0x3FF) * 16336u + 32768u) >> 16;
uint32_t g = (((px >> 10) & 0x3FF) * 16336u + 32768u) >> 16;
uint32_t b = (((px >> 20) & 0x3FF) * 16336u + 32768u) >> 16;
uint32_t a = ((px >> 30) & 0x3) * 85u;
dstRow[x] = r | (g << 8) | (b << 16) | (a << 24);
}
}
}
// Converts an RGBA_1010102 bitmap (what a 10-bit HEIC/AVIF decodes to on API 33+) into a freshly
// malloc'd RGBA_8888 buffer. Fills outInfo with {width, height, rowBytes} and returns the buffer
// address, or 0 (so the caller falls back to a Skia copy) if the bitmap isn't 1010102 or can't be
// locked. Same ownership contract as rotate: free the returned buffer via NativeBuffer.free.
JNIEXPORT jlong JNICALL
Java_app_alextran_immich_NativeImage_convert1010102(
JNIEnv *env, jclass clazz, jobject bitmap, jintArray outInfo) {
AndroidBitmapInfo info;
if (AndroidBitmap_getInfo(env, bitmap, &info) != ANDROID_BITMAP_RESULT_SUCCESS) {
return 0;
}
if (info.format != ANDROID_BITMAP_FORMAT_RGBA_1010102) {
return 0;
}
int w = (int) info.width;
int h = (int) info.height;
uint32_t *dst = (uint32_t *) malloc((size_t) w * h * 4);
if (dst == NULL) {
return 0;
}
void *srcPixels = NULL;
if (AndroidBitmap_lockPixels(env, bitmap, &srcPixels) != ANDROID_BITMAP_RESULT_SUCCESS) {
free(dst);
return 0;
}
convert_1010102((const uint8_t *) srcPixels, (int) info.stride, dst, w, h);
AndroidBitmap_unlockPixels(env, bitmap);
jint dims[3] = {w, h, w * 4};
(*env)->SetIntArrayRegion(env, outInfo, 0, 3, dims);
if ((*env)->ExceptionCheck(env)) {
free(dst);
return 0;
}
return (jlong) dst;
}
@@ -16,14 +16,4 @@ object NativeImage {
*/ */
@JvmStatic @JvmStatic
external fun rotate(bitmap: Bitmap, orientation: Int, outInfo: IntArray): Long external fun rotate(bitmap: Bitmap, orientation: Int, outInfo: IntArray): Long
/**
* Converts an RGBA_1010102 [bitmap] (what a 10-bit HEIC/AVIF decodes to on API 33+) to RGBA_8888,
* writing the result into a freshly malloc'd native buffer in one pass, with no intermediate
* ARGB_8888 bitmap. Returns the buffer address (free it with [NativeBuffer.free]) and fills
* [outInfo] with {width, height, rowBytes}. Returns 0 when the bitmap isn't RGBA_1010102 so the
* caller can fall back to a Skia copy.
*/
@JvmStatic
external fun convert1010102(bitmap: Bitmap, outInfo: IntArray): Long
} }
@@ -46,47 +46,22 @@ inline fun ImageDecoder.Source.decodeBitmap(target: Size = Size(0, 0)): Bitmap {
} }
fun Bitmap.toNativeBuffer(): Map<String, Long> { fun Bitmap.toNativeBuffer(): Map<String, Long> {
// Dart reads the buffer as rgba8888, but 10-bit sources decode to RGBA_1010102, which garbles val size = width * height * 4
// colors when copied verbatim. Convert those straight into the output buffer in native code -
// one pass, no intermediate ARGB_8888 bitmap.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && config == Bitmap.Config.RGBA_1010102) {
val info = IntArray(3)
val pointer = NativeImage.convert1010102(this, info)
if (pointer != 0L) {
recycle()
return mapOf(
"pointer" to pointer,
"width" to info[0].toLong(),
"height" to info[1].toLong(),
"rowBytes" to info[2].toLong()
)
}
// native convert declined (OOM/lock) -> fall through to the Skia copy path below.
}
// Other non-8888 configs (e.g. HDR F16) still need Skia's convert; 8-bit is copied as-is.
val bitmap = if (config != Bitmap.Config.ARGB_8888) {
val converted = copy(Bitmap.Config.ARGB_8888, false)
recycle()
converted ?: throw IOException("could not convert bitmap to ARGB_8888")
} else {
this
}
val size = bitmap.width * bitmap.height * 4
val pointer = NativeBuffer.allocate(size) val pointer = NativeBuffer.allocate(size)
try { try {
val buffer = NativeBuffer.wrap(pointer, size) val buffer = NativeBuffer.wrap(pointer, size)
bitmap.copyPixelsToBuffer(buffer) copyPixelsToBuffer(buffer)
return mapOf( return mapOf(
"pointer" to pointer, "pointer" to pointer,
"width" to bitmap.width.toLong(), "width" to width.toLong(),
"height" to bitmap.height.toLong(), "height" to height.toLong(),
"rowBytes" to (bitmap.width * 4).toLong() "rowBytes" to (width * 4).toLong()
) )
} catch (e: Throwable) { } catch (e: Exception) {
NativeBuffer.free(pointer) NativeBuffer.free(pointer)
throw e throw e
} finally { } finally {
bitmap.recycle() recycle()
} }
} }
@@ -1,8 +1,6 @@
package app.alextran.immich.images package app.alextran.immich.images
import android.content.Context import android.content.Context
import android.graphics.ImageDecoder
import android.os.Build
import android.os.CancellationSignal import android.os.CancellationSignal
import android.os.OperationCanceledException import android.os.OperationCanceledException
import app.alextran.immich.INITIAL_BUFFER_SIZE import app.alextran.immich.INITIAL_BUFFER_SIZE
@@ -24,7 +22,6 @@ import java.io.File
import java.io.IOException import java.io.IOException
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
private const val MAX_PREALLOC_BYTES = 128 * 1024 * 1024 private const val MAX_PREALLOC_BYTES = 128 * 1024 * 1024
@@ -39,16 +36,12 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
companion object { companion object {
val CANCELLED = Result.success<Map<String, Long>?>(null) val CANCELLED = Result.success<Map<String, Long>?>(null)
// Shared, process-lifetime pool: RemoteImagesImpl is re-created per FlutterEngine, so a
// per-instance pool would leak threads across engine restarts.
private val decodeExecutor = Executors.newFixedThreadPool(2)
} }
override fun requestImage( override fun requestImage(
url: String, url: String,
requestId: Long, requestId: Long,
preferEncoded: Boolean, @Suppress("UNUSED_PARAMETER") preferEncoded: Boolean, // always returns encoded; setting has no effect on Android
callback: (Result<Map<String, Long>?>) -> Unit callback: (Result<Map<String, Long>?>) -> Unit
) { ) {
val signal = CancellationSignal() val signal = CancellationSignal()
@@ -58,51 +51,12 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
url, url,
signal, signal,
onSuccess = { buffer -> onSuccess = { buffer ->
requestMap.remove(requestId)
if (signal.isCanceled) { if (signal.isCanceled) {
requestMap.remove(requestId) NativeBuffer.free(buffer.pointer)
buffer.free()
return@fetch callback(CANCELLED) return@fetch callback(CANCELLED)
} }
// Decode natively when the caller wants pixels: Flutter's fallback decoder copies
// 10-bit bitmaps (RGBA_1010102) as if they were rgba8888, garbling colors. Decode on a
// dedicated pool - the fetch callback threads are shared with video streaming. On any
// decode failure (including OOM on huge originals), hand Flutter the encoded bytes as
// before.
if (!preferEncoded && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
decodeExecutor.execute {
val res = if (signal.isCanceled) null else try {
val source = ImageDecoder.createSource(NativeBuffer.wrap(buffer.pointer, buffer.offset))
source.decodeBitmap().toNativeBuffer()
} catch (_: Throwable) {
null
}
requestMap.remove(requestId)
when {
// Deliver even if the request was cancelled meanwhile: re-checking here would orphan
// res's malloc, and Dart frees the buffer itself when it sees the cancel.
res != null -> {
buffer.free()
callback(Result.success(res))
}
signal.isCanceled -> {
buffer.free()
callback(CANCELLED)
}
else -> callback(
Result.success(
mapOf(
"pointer" to buffer.pointer,
"length" to buffer.offset.toLong()
)
)
)
}
}
return@fetch
}
requestMap.remove(requestId)
callback( callback(
Result.success( Result.success(
mapOf( mapOf(
File diff suppressed because it is too large Load Diff
@@ -70,8 +70,7 @@
debugDocumentVersioning = "YES" debugDocumentVersioning = "YES"
debugServiceExtension = "internal" debugServiceExtension = "internal"
enableGPUValidationMode = "1" enableGPUValidationMode = "1"
allowLocationSimulation = "YES" allowLocationSimulation = "YES">
queueDebuggingEnabled = "NO">
<BuildableProductRunnable <BuildableProductRunnable
runnableDebuggingMode = "0"> runnableDebuggingMode = "0">
<BuildableReference <BuildableReference
+14 -60
View File
@@ -1,65 +1,19 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class CropAspectRatio { enum AspectRatioPreset {
final int? numerator; free(ratio: null, label: 'Free', icon: Icons.crop_free_rounded),
final int? denominator; square(ratio: 1.0, label: '1:1', icon: Icons.crop_square_rounded),
ratio16x9(ratio: 16 / 9, label: '16:9', icon: Icons.crop_16_9_rounded),
ratio3x2(ratio: 3 / 2, label: '3:2', icon: Icons.crop_3_2_rounded),
ratio7x5(ratio: 7 / 5, label: '7:5', icon: Icons.crop_7_5_rounded),
ratio9x16(ratio: 9 / 16, label: '9:16', icon: Icons.crop_16_9_rounded, iconRotated: true),
ratio2x3(ratio: 2 / 3, label: '2:3', icon: Icons.crop_3_2_rounded, iconRotated: true),
ratio5x7(ratio: 5 / 7, label: '5:7', icon: Icons.crop_7_5_rounded, iconRotated: true);
final String? customLabel; final double? ratio;
final IconData? icon; final String label;
final IconData icon;
final bool iconRotated;
const CropAspectRatio({this.numerator, this.denominator, this.customLabel, this.icon}); const AspectRatioPreset({required this.ratio, required this.label, required this.icon, this.iconRotated = false});
static const free = CropAspectRatio(customLabel: "Free", icon: Icons.crop_free);
static const original = CropAspectRatio(customLabel: "Original", icon: Icons.crop_original);
String get label {
return customLabel ?? (numerator != null && denominator != null ? '$numerator:$denominator' : 'Free');
}
bool get hasFlippedVariant => numerator != denominator;
double? get ratio => (numerator != null && denominator != null) ? numerator! / denominator! : null;
CropAspectRatio get flipped {
return CropAspectRatio(numerator: denominator, denominator: numerator, customLabel: customLabel, icon: icon);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) {
return true;
}
return other is CropAspectRatio &&
other.numerator == numerator &&
other.denominator == denominator &&
other.customLabel == customLabel &&
other.icon == icon;
}
@override
int get hashCode {
return numerator.hashCode ^ denominator.hashCode ^ customLabel.hashCode ^ icon.hashCode;
}
} }
const aspectRatioFree = CropAspectRatio(customLabel: "Free", icon: Icons.crop_free);
const aspectRatioOriginal = CropAspectRatio(customLabel: "Original", icon: Icons.crop_original);
final aspectRatioPresets = [
CropAspectRatio.free,
CropAspectRatio.original,
const CropAspectRatio(numerator: 1, denominator: 1),
// lanscape
const CropAspectRatio(numerator: 16, denominator: 9),
const CropAspectRatio(numerator: 3, denominator: 2),
const CropAspectRatio(numerator: 7, denominator: 5),
const CropAspectRatio(numerator: 4, denominator: 3),
// portrait
const CropAspectRatio(numerator: 16, denominator: 9).flipped,
const CropAspectRatio(numerator: 3, denominator: 2).flipped,
const CropAspectRatio(numerator: 7, denominator: 5).flipped,
const CropAspectRatio(numerator: 4, denominator: 3).flipped,
];
@@ -27,6 +27,7 @@ sealed class BaseAsset {
final int? height; final int? height;
final int? durationMs; final int? durationMs;
final bool isFavorite; final bool isFavorite;
final String? livePhotoVideoId;
final bool isEdited; final bool isEdited;
const BaseAsset({ const BaseAsset({
@@ -39,15 +40,32 @@ sealed class BaseAsset {
this.height, this.height,
this.durationMs, this.durationMs,
this.isFavorite = false, this.isFavorite = false,
this.livePhotoVideoId,
required this.isEdited, required this.isEdited,
}); });
bool get isImage => type == AssetType.image; bool get isImage => type == AssetType.image;
bool get isVideo => type == AssetType.video; bool get isVideo => type == AssetType.video;
bool get isMotionPhoto => playbackStyle == AssetPlaybackStyle.livePhoto; bool get isMotionPhoto => livePhotoVideoId != null;
bool get isAnimatedImage => playbackStyle == AssetPlaybackStyle.imageAnimated; bool get isAnimatedImage => playbackStyle == AssetPlaybackStyle.imageAnimated;
AssetPlaybackStyle get playbackStyle {
if (isVideo) {
return AssetPlaybackStyle.video;
}
if (isMotionPhoto) {
return AssetPlaybackStyle.livePhoto;
}
if (isImage && durationMs != null && durationMs! > 0) {
return AssetPlaybackStyle.imageAnimated;
}
if (isImage) {
return AssetPlaybackStyle.image;
}
return AssetPlaybackStyle.unknown;
}
Duration get duration { Duration get duration {
final durationMs = this.durationMs; final durationMs = this.durationMs;
if (durationMs != null) { if (durationMs != null) {
@@ -68,7 +86,6 @@ sealed class BaseAsset {
String? get localId; String? get localId;
String? get remoteId; String? get remoteId;
String get heroTag; String get heroTag;
AssetPlaybackStyle get playbackStyle;
@override @override
String toString() { String toString() {
@@ -25,6 +25,7 @@ class LocalAsset extends BaseAsset {
super.height, super.height,
super.durationMs, super.durationMs,
super.isFavorite = false, super.isFavorite = false,
super.livePhotoVideoId,
this.orientation = 0, this.orientation = 0,
required this.playbackStyle, required this.playbackStyle,
this.adjustmentTime, this.adjustmentTime,
@@ -10,7 +10,6 @@ class RemoteAsset extends BaseAsset {
final AssetVisibility visibility; final AssetVisibility visibility;
final String ownerId; final String ownerId;
final String? stackId; final String? stackId;
final String? livePhotoVideoId;
final DateTime? uploadedAt; final DateTime? uploadedAt;
final DateTime? deletedAt; final DateTime? deletedAt;
@@ -30,7 +29,7 @@ class RemoteAsset extends BaseAsset {
super.isFavorite = false, super.isFavorite = false,
this.thumbHash, this.thumbHash,
this.visibility = AssetVisibility.timeline, this.visibility = AssetVisibility.timeline,
this.livePhotoVideoId, super.livePhotoVideoId,
this.stackId, this.stackId,
required super.isEdited, required super.isEdited,
this.deletedAt, this.deletedAt,
@@ -39,23 +38,6 @@ class RemoteAsset extends BaseAsset {
@override @override
String? get localId => localAssetId; String? get localId => localAssetId;
@override
AssetPlaybackStyle get playbackStyle {
if (isVideo) {
return AssetPlaybackStyle.video;
}
if (livePhotoVideoId != null) {
return AssetPlaybackStyle.livePhoto;
}
if (isImage && durationMs != null && durationMs! > 0) {
return AssetPlaybackStyle.imageAnimated;
}
if (isImage) {
return AssetPlaybackStyle.image;
}
return AssetPlaybackStyle.unknown;
}
@override @override
String? get remoteId => id; String? get remoteId => id;
@@ -108,7 +90,6 @@ class RemoteAsset extends BaseAsset {
thumbHash == other.thumbHash && thumbHash == other.thumbHash &&
visibility == other.visibility && visibility == other.visibility &&
stackId == other.stackId && stackId == other.stackId &&
livePhotoVideoId == other.livePhotoVideoId &&
uploadedAt == other.uploadedAt && uploadedAt == other.uploadedAt &&
deletedAt == other.deletedAt; deletedAt == other.deletedAt;
} }
@@ -122,7 +103,6 @@ class RemoteAsset extends BaseAsset {
thumbHash.hashCode ^ thumbHash.hashCode ^
visibility.hashCode ^ visibility.hashCode ^
stackId.hashCode ^ stackId.hashCode ^
livePhotoVideoId.hashCode ^
uploadedAt.hashCode ^ uploadedAt.hashCode ^
deletedAt.hashCode; deletedAt.hashCode;
@@ -26,10 +26,6 @@ class AssetService {
return _localRepository.getByChecksum(checksum); return _localRepository.getByChecksum(checksum);
} }
Future<LocalAsset?> getLocalAsset(String id) {
return _localRepository.get(id);
}
Future<RemoteAsset?> getRemoteAssetByChecksum(String checksum) { Future<RemoteAsset?> getRemoteAssetByChecksum(String checksum) {
return _remoteRepository.getByChecksum(checksum); return _remoteRepository.getByChecksum(checksum);
} }
@@ -85,12 +85,8 @@ class TimelineFactory {
TimelineService fromAssetsWithBuckets(List<BaseAsset> assets, TimelineOrigin type) => TimelineService fromAssetsWithBuckets(List<BaseAsset> assets, TimelineOrigin type) =>
TimelineService(_timelineRepository.fromAssetsWithBuckets(assets, type)); TimelineService(_timelineRepository.fromAssetsWithBuckets(assets, type));
/// Creates a TimelineService for serving geographical map queries, such assets within bounded locations TimelineService map(List<String> userIds, TimelineMapOptions options) =>
TimelineService geographicMap( TimelineService(_timelineRepository.map(userIds, options, groupBy));
List<String> userIds,
TimelineMapOptions Function() currentOptions,
Stream<TimelineMapOptions> optionsStream,
) => TimelineService(_timelineRepository.geographicMap(userIds, currentOptions, optionsStream, groupBy));
} }
class TimelineService { class TimelineService {
@@ -21,7 +21,6 @@ WHERE (library_id IS NOT NULL);
CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created
ON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC) ON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC)
''') ''')
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_remote_asset_uploaded ON remote_asset_entity (uploaded_at)')
class RemoteAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin { class RemoteAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
const RemoteAssetEntity(); const RemoteAssetEntity();
@@ -1779,7 +1779,3 @@ i0.Index get idxRemoteAssetOwnerVisibilityDeletedCreated => i0.Index(
'idx_remote_asset_owner_visibility_deleted_created', 'idx_remote_asset_owner_visibility_deleted_created',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created ON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC)', 'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created ON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC)',
); );
i0.Index get idxRemoteAssetUploaded => i0.Index(
'idx_remote_asset_uploaded',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_uploaded ON remote_asset_entity (uploaded_at)',
);
@@ -12,7 +12,7 @@ class RemoteImageRequest extends ImageRequest {
} }
final info = await remoteImageApi.requestImage(uri, requestId: requestId, preferEncoded: false); final info = await remoteImageApi.requestImage(uri, requestId: requestId, preferEncoded: false);
// Android falls back to encoded data if native decoding fails, so check for both shapes of the response. // Android always returns encoded data, so we need to check for both shapes of the response.
final frame = switch (info) { final frame = switch (info) {
{'pointer': int pointer, 'length': int length} => await _fromEncodedPlatformImage(pointer, length), {'pointer': int pointer, 'length': int length} => await _fromEncodedPlatformImage(pointer, length),
{'pointer': int pointer, 'width': int width, 'height': int height, 'rowBytes': int rowBytes} => {'pointer': int pointer, 'width': int width, 'height': int height, 'rowBytes': int rowBytes} =>
@@ -120,7 +120,7 @@ class Drift extends $Drift {
} }
@override @override
int get schemaVersion => 31; int get schemaVersion => 30;
@override @override
MigrationStrategy get migration => MigrationStrategy( MigrationStrategy get migration => MigrationStrategy(
@@ -128,209 +128,199 @@ class Drift extends $Drift {
// Run migration steps without foreign keys and re-enable them later // Run migration steps without foreign keys and re-enable them later
await customStatement('PRAGMA foreign_keys = OFF'); await customStatement('PRAGMA foreign_keys = OFF');
try { await m.runMigrationSteps(
await transaction( from: from,
() => m.runMigrationSteps( to: to,
from: from, steps: migrationSteps(
to: to, from1To2: (m, v2) async {
steps: migrationSteps( for (final entity in v2.entities) {
from1To2: (m, v2) async { await m.drop(entity);
for (final entity in v2.entities) { await m.create(entity);
await m.drop(entity); }
await m.create(entity); },
} from2To3: (m, v3) async {
}, // Removed foreign key constraint on stack.primaryAssetId
from2To3: (m, v3) async { await m.alterTable(TableMigration(v3.stackEntity));
// Removed foreign key constraint on stack.primaryAssetId },
await m.alterTable(TableMigration(v3.stackEntity)); from3To4: (m, v4) async {
}, // Thumbnail path column got removed from person_entity
from3To4: (m, v4) async { await m.alterTable(TableMigration(v4.personEntity));
// Thumbnail path column got removed from person_entity // asset_face_entity is added
await m.alterTable(TableMigration(v4.personEntity)); await m.create(v4.assetFaceEntity);
// asset_face_entity is added },
await m.create(v4.assetFaceEntity); from4To5: (m, v5) async {
}, await m.alterTable(
from4To5: (m, v5) async { TableMigration(
await m.alterTable( v5.userEntity,
TableMigration( newColumns: [v5.userEntity.hasProfileImage, v5.userEntity.profileChangedAt],
v5.userEntity, columnTransformer: {v5.userEntity.profileChangedAt: currentDateAndTime},
newColumns: [v5.userEntity.hasProfileImage, v5.userEntity.profileChangedAt], ),
columnTransformer: {v5.userEntity.profileChangedAt: currentDateAndTime}, );
), },
); from5To6: (m, v6) async {
}, // Drops the (checksum, ownerId) and adds it back as (ownerId, checksum)
from5To6: (m, v6) async { await customStatement('DROP INDEX IF EXISTS UQ_remote_asset_owner_checksum');
// Drops the (checksum, ownerId) and adds it back as (ownerId, checksum) await m.drop(v6.idxRemoteAssetOwnerChecksum);
await customStatement('DROP INDEX IF EXISTS UQ_remote_asset_owner_checksum'); await m.create(v6.idxRemoteAssetOwnerChecksum);
await m.drop(v6.idxRemoteAssetOwnerChecksum); // Adds libraryId to remote_asset_entity
await m.create(v6.idxRemoteAssetOwnerChecksum); await m.addColumn(v6.remoteAssetEntity, v6.remoteAssetEntity.libraryId);
// Adds libraryId to remote_asset_entity await m.drop(v6.uQRemoteAssetsOwnerChecksum);
await m.addColumn(v6.remoteAssetEntity, v6.remoteAssetEntity.libraryId); await m.create(v6.uQRemoteAssetsOwnerChecksum);
await m.drop(v6.uQRemoteAssetsOwnerChecksum); await m.drop(v6.uQRemoteAssetsOwnerLibraryChecksum);
await m.create(v6.uQRemoteAssetsOwnerChecksum); await m.create(v6.uQRemoteAssetsOwnerLibraryChecksum);
await m.drop(v6.uQRemoteAssetsOwnerLibraryChecksum); },
await m.create(v6.uQRemoteAssetsOwnerLibraryChecksum); from6To7: (m, v7) async {
}, await m.createIndex(v7.idxLatLng);
from6To7: (m, v7) async { },
await m.createIndex(v7.idxLatLng); from7To8: (m, v8) async {
}, await m.create(v8.storeEntity);
from7To8: (m, v8) async { },
await m.create(v8.storeEntity); from8To9: (m, v9) async {
}, await m.addColumn(v9.localAlbumEntity, v9.localAlbumEntity.linkedRemoteAlbumId);
from8To9: (m, v9) async { },
await m.addColumn(v9.localAlbumEntity, v9.localAlbumEntity.linkedRemoteAlbumId); from9To10: (m, v10) async {
}, await m.createTable(v10.authUserEntity);
from9To10: (m, v10) async { await m.addColumn(v10.userEntity, v10.userEntity.avatarColor);
await m.createTable(v10.authUserEntity); await m.alterTable(TableMigration(v10.userEntity));
await m.addColumn(v10.userEntity, v10.userEntity.avatarColor); },
await m.alterTable(TableMigration(v10.userEntity)); from10To11: (m, v11) async {
}, await m.addColumn(v11.localAlbumAssetEntity, v11.localAlbumAssetEntity.marker_);
from10To11: (m, v11) async { },
await m.addColumn(v11.localAlbumAssetEntity, v11.localAlbumAssetEntity.marker_); from11To12: (m, v12) async {
}, final localToUTCMapping = {
from11To12: (m, v12) async { v12.localAssetEntity: [v12.localAssetEntity.createdAt, v12.localAssetEntity.updatedAt],
final localToUTCMapping = { v12.localAlbumEntity: [v12.localAlbumEntity.updatedAt],
v12.localAssetEntity: [v12.localAssetEntity.createdAt, v12.localAssetEntity.updatedAt], };
v12.localAlbumEntity: [v12.localAlbumEntity.updatedAt],
};
for (final entry in localToUTCMapping.entries) { for (final entry in localToUTCMapping.entries) {
final table = entry.key; final table = entry.key;
await m.alterTable( await m.alterTable(
TableMigration( TableMigration(
table, table,
columnTransformer: { columnTransformer: {
for (final column in entry.value) for (final column in entry.value)
column: column.modify(const DateTimeModifier.utc()).strftime('%Y-%m-%dT%H:%M:%fZ'), column: column.modify(const DateTimeModifier.utc()).strftime('%Y-%m-%dT%H:%M:%fZ'),
}, },
), ),
); );
} }
}, },
from12To13: (m, v13) async { from12To13: (m, v13) async {
await m.create(v13.trashedLocalAssetEntity); await m.create(v13.trashedLocalAssetEntity);
await m.createIndex(v13.idxTrashedLocalAssetChecksum); await m.createIndex(v13.idxTrashedLocalAssetChecksum);
await m.createIndex(v13.idxTrashedLocalAssetAlbum); await m.createIndex(v13.idxTrashedLocalAssetAlbum);
}, },
from13To14: (m, v14) async { from13To14: (m, v14) async {
await m.addColumn(v14.localAssetEntity, v14.localAssetEntity.adjustmentTime); await m.addColumn(v14.localAssetEntity, v14.localAssetEntity.adjustmentTime);
await m.addColumn(v14.localAssetEntity, v14.localAssetEntity.latitude); await m.addColumn(v14.localAssetEntity, v14.localAssetEntity.latitude);
await m.addColumn(v14.localAssetEntity, v14.localAssetEntity.longitude); await m.addColumn(v14.localAssetEntity, v14.localAssetEntity.longitude);
}, },
from14To15: (m, v15) async { from14To15: (m, v15) async {
await m.alterTable( await m.alterTable(
TableMigration( TableMigration(
v15.trashedLocalAssetEntity, v15.trashedLocalAssetEntity,
columnTransformer: {v15.trashedLocalAssetEntity.source: Constant(TrashOrigin.localSync.index)}, columnTransformer: {v15.trashedLocalAssetEntity.source: Constant(TrashOrigin.localSync.index)},
newColumns: [v15.trashedLocalAssetEntity.source], newColumns: [v15.trashedLocalAssetEntity.source],
), ),
); );
}, },
from15To16: (m, v16) async { from15To16: (m, v16) async {
// Add i_cloud_id to local and remote asset tables // Add i_cloud_id to local and remote asset tables
await m.addColumn(v16.localAssetEntity, v16.localAssetEntity.iCloudId); await m.addColumn(v16.localAssetEntity, v16.localAssetEntity.iCloudId);
await m.createIndex(v16.idxLocalAssetCloudId); await m.createIndex(v16.idxLocalAssetCloudId);
await m.createTable(v16.remoteAssetCloudIdEntity); await m.createTable(v16.remoteAssetCloudIdEntity);
}, },
from16To17: (m, v17) async { from16To17: (m, v17) async {
await m.addColumn(v17.remoteAssetEntity, v17.remoteAssetEntity.isEdited); await m.addColumn(v17.remoteAssetEntity, v17.remoteAssetEntity.isEdited);
}, },
from17To18: (m, v18) async { from17To18: (m, v18) async {
await m.createIndex(v18.idxRemoteAssetCloudId); await m.createIndex(v18.idxRemoteAssetCloudId);
}, },
from18To19: (m, v19) async { from18To19: (m, v19) async {
await m.createIndex(v19.idxAssetFacePersonId); await m.createIndex(v19.idxAssetFacePersonId);
await m.createIndex(v19.idxAssetFaceAssetId); await m.createIndex(v19.idxAssetFaceAssetId);
await m.createIndex(v19.idxLocalAlbumAssetAlbumAsset); await m.createIndex(v19.idxLocalAlbumAssetAlbumAsset);
await m.createIndex(v19.idxPartnerSharedWithId); await m.createIndex(v19.idxPartnerSharedWithId);
await m.createIndex(v19.idxPersonOwnerId); await m.createIndex(v19.idxPersonOwnerId);
await m.createIndex(v19.idxRemoteAlbumOwnerId); await m.createIndex(v19.idxRemoteAlbumOwnerId);
await m.createIndex(v19.idxRemoteAlbumAssetAlbumAsset); await m.createIndex(v19.idxRemoteAlbumAssetAlbumAsset);
await m.createIndex(v19.idxRemoteAssetStackId); await m.createIndex(v19.idxRemoteAssetStackId);
await m.createIndex(v19.idxRemoteAssetLocalDateTimeDay); await m.createIndex(v19.idxRemoteAssetLocalDateTimeDay);
await m.createIndex(v19.idxRemoteAssetLocalDateTimeMonth); await m.createIndex(v19.idxRemoteAssetLocalDateTimeMonth);
await m.createIndex(v19.idxStackPrimaryAssetId); await m.createIndex(v19.idxStackPrimaryAssetId);
}, },
from19To20: (m, v20) async { from19To20: (m, v20) async {
await m.addColumn(v20.assetFaceEntity, v20.assetFaceEntity.isVisible); await m.addColumn(v20.assetFaceEntity, v20.assetFaceEntity.isVisible);
await m.addColumn(v20.assetFaceEntity, v20.assetFaceEntity.deletedAt); await m.addColumn(v20.assetFaceEntity, v20.assetFaceEntity.deletedAt);
}, },
from20To21: (m, v21) async { from20To21: (m, v21) async {
await m.addColumn(v21.localAssetEntity, v21.localAssetEntity.playbackStyle); await m.addColumn(v21.localAssetEntity, v21.localAssetEntity.playbackStyle);
await m.addColumn(v21.trashedLocalAssetEntity, v21.trashedLocalAssetEntity.playbackStyle); await m.addColumn(v21.trashedLocalAssetEntity, v21.trashedLocalAssetEntity.playbackStyle);
}, },
from21To22: (m, v22) async { from21To22: (m, v22) async {
await m.createTable(v22.assetEditEntity); await m.createTable(v22.assetEditEntity);
await m.createIndex(v22.idxAssetEditAssetId); await m.createIndex(v22.idxAssetEditAssetId);
}, },
from22To23: (m, v23) async { from22To23: (m, v23) async {
await m.renameColumn(v23.localAssetEntity, 'duration_in_seconds', v23.localAssetEntity.durationMs); await m.renameColumn(v23.localAssetEntity, 'duration_in_seconds', v23.localAssetEntity.durationMs);
await m.renameColumn(v23.remoteAssetEntity, 'duration_in_seconds', v23.remoteAssetEntity.durationMs); await m.renameColumn(v23.remoteAssetEntity, 'duration_in_seconds', v23.remoteAssetEntity.durationMs);
await m.renameColumn( await m.renameColumn(
v23.trashedLocalAssetEntity, v23.trashedLocalAssetEntity,
'duration_in_seconds', 'duration_in_seconds',
v23.trashedLocalAssetEntity.durationMs, v23.trashedLocalAssetEntity.durationMs,
); );
await localAssetEntity.update().write( await localAssetEntity.update().write(
LocalAssetEntityCompanion.custom(durationMs: v23.localAssetEntity.durationMs * const Constant(1000)), LocalAssetEntityCompanion.custom(durationMs: v23.localAssetEntity.durationMs * const Constant(1000)),
); );
await remoteAssetEntity.update().write( await remoteAssetEntity.update().write(
RemoteAssetEntityCompanion.custom( RemoteAssetEntityCompanion.custom(durationMs: v23.remoteAssetEntity.durationMs * const Constant(1000)),
durationMs: v23.remoteAssetEntity.durationMs * const Constant(1000), );
), await trashedLocalAssetEntity.update().write(
); TrashedLocalAssetEntityCompanion.custom(
await trashedLocalAssetEntity.update().write( durationMs: v23.trashedLocalAssetEntity.durationMs * const Constant(1000),
TrashedLocalAssetEntityCompanion.custom( ),
durationMs: v23.trashedLocalAssetEntity.durationMs * const Constant(1000), );
), },
); from23To24: (m, v24) async {
}, await customStatement('DROP INDEX IF EXISTS idx_remote_album_owner_id');
from23To24: (m, v24) async { await m.alterTable(TableMigration(v24.remoteAlbumEntity));
await customStatement('DROP INDEX IF EXISTS idx_remote_album_owner_id'); },
await m.alterTable(TableMigration(v24.remoteAlbumEntity)); from24To25: (m, v25) async {
}, await m.createTable(v25.metadata);
from24To25: (m, v25) async { await customStatement('DROP INDEX IF EXISTS idx_remote_asset_owner_checksum');
await m.createTable(v25.metadata); await customStatement('DROP INDEX IF EXISTS idx_remote_asset_local_date_time_day');
await customStatement('DROP INDEX IF EXISTS idx_remote_asset_owner_checksum'); await customStatement('DROP INDEX IF EXISTS idx_remote_asset_local_date_time_month');
await customStatement('DROP INDEX IF EXISTS idx_remote_asset_local_date_time_day'); await m.createIndex(v25.idxRemoteAssetOwnerVisibilityDeletedCreated);
await customStatement('DROP INDEX IF EXISTS idx_remote_asset_local_date_time_month'); await m.createIndex(v25.idxRemoteExifCity);
await m.createIndex(v25.idxRemoteAssetOwnerVisibilityDeletedCreated); await m.createIndex(v25.idxAssetFaceVisiblePerson);
await m.createIndex(v25.idxRemoteExifCity); },
await m.createIndex(v25.idxAssetFaceVisiblePerson); from25To26: (m, v26) async {
}, await m.addColumn(v26.remoteAssetEntity, v26.remoteAssetEntity.uploadedAt);
from25To26: (m, v26) async { },
await m.addColumn(v26.remoteAssetEntity, v26.remoteAssetEntity.uploadedAt); from26To27: (m, v27) async {
}, await customStatement('ALTER TABLE metadata RENAME TO settings');
from26To27: (m, v27) async { },
await customStatement('ALTER TABLE metadata RENAME TO settings'); from27To28: (m, v28) async {
}, await m.createIndex(v28.idxLocalAssetCreatedAt);
from27To28: (m, v28) async { },
await m.createIndex(v28.idxLocalAssetCreatedAt); from28To29: (m, v29) async {
}, await m.createTable(v29.assetOcrEntity);
from28To29: (m, v29) async { await m.createIndex(v29.idxAssetOcrAssetId);
await m.createTable(v29.assetOcrEntity); },
await m.createIndex(v29.idxAssetOcrAssetId); from29To30: (m, v30) async {
}, await m.alterTable(TableMigration(v30.settings));
from29To30: (m, v30) async { },
await m.alterTable(TableMigration(v30.settings)); ),
}, );
from30To31: (m, v31) async {
await m.createIndex(v31.idxRemoteAssetUploaded);
},
),
),
);
if (kDebugMode) { if (kDebugMode) {
// Fail if the migration broke foreign keys // Fail if the migration broke foreign keys
final wrongFKs = await customSelect('PRAGMA foreign_key_check').get(); final wrongFKs = await customSelect('PRAGMA foreign_key_check').get();
assert(wrongFKs.isEmpty, '${wrongFKs.map((e) => e.data)}'); assert(wrongFKs.isEmpty, '${wrongFKs.map((e) => e.data)}');
}
} finally {
await customStatement('PRAGMA foreign_keys = ON;');
} }
await customStatement('PRAGMA foreign_keys = ON;');
await optimize(); await optimize();
}, },
beforeOpen: (details) async { beforeOpen: (details) async {
@@ -124,7 +124,6 @@ abstract class $Drift extends i0.GeneratedDatabase {
i2.idxRemoteAssetChecksum, i2.idxRemoteAssetChecksum,
i2.idxRemoteAssetStackId, i2.idxRemoteAssetStackId,
i2.idxRemoteAssetOwnerVisibilityDeletedCreated, i2.idxRemoteAssetOwnerVisibilityDeletedCreated,
i2.idxRemoteAssetUploaded,
authUserEntity, authUserEntity,
userMetadataEntity, userMetadataEntity,
partnerEntity, partnerEntity,
@@ -15920,592 +15920,6 @@ i1.GeneratedColumn<String> _column_224(String aliasedName) =>
type: i1.DriftSqlType.string, type: i1.DriftSqlType.string,
$customConstraints: 'NULL', $customConstraints: 'NULL',
); );
final class Schema31 extends i0.VersionedSchema {
Schema31({required super.database}) : super(version: 31);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
userEntity,
remoteAssetEntity,
stackEntity,
localAssetEntity,
remoteAlbumEntity,
localAlbumEntity,
localAlbumAssetEntity,
idxLocalAlbumAssetAlbumAsset,
idxLocalAssetChecksum,
idxLocalAssetCloudId,
idxLocalAssetCreatedAt,
idxStackPrimaryAssetId,
uQRemoteAssetsOwnerChecksum,
uQRemoteAssetsOwnerLibraryChecksum,
idxRemoteAssetChecksum,
idxRemoteAssetStackId,
idxRemoteAssetOwnerVisibilityDeletedCreated,
idxRemoteAssetUploaded,
authUserEntity,
userMetadataEntity,
partnerEntity,
remoteExifEntity,
remoteAlbumAssetEntity,
remoteAlbumUserEntity,
remoteAssetCloudIdEntity,
memoryEntity,
memoryAssetEntity,
personEntity,
assetFaceEntity,
storeEntity,
trashedLocalAssetEntity,
assetEditEntity,
settings,
assetOcrEntity,
idxPartnerSharedWithId,
idxLatLng,
idxRemoteExifCity,
idxRemoteAlbumAssetAlbumAsset,
idxRemoteAssetCloudId,
idxPersonOwnerId,
idxAssetFacePersonId,
idxAssetFaceAssetId,
idxAssetFaceVisiblePerson,
idxTrashedLocalAssetChecksum,
idxTrashedLocalAssetAlbum,
idxAssetEditAssetId,
idxAssetOcrAssetId,
];
late final Shape33 userEntity = Shape33(
source: i0.VersionedTable(
entityName: 'user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_109,
_column_110,
_column_111,
_column_112,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape50 remoteAssetEntity = Shape50(
source: i0.VersionedTable(
entityName: 'remote_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_107,
_column_119,
_column_120,
_column_121,
_column_122,
_column_123,
_column_124,
_column_212,
_column_125,
_column_126,
_column_127,
_column_128,
_column_129,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape35 stackEntity = Shape35(
source: i0.VersionedTable(
entityName: 'stack_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_114,
_column_115,
_column_121,
_column_130,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape36 localAssetEntity = Shape36(
source: i0.VersionedTable(
entityName: 'local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_107,
_column_131,
_column_120,
_column_132,
_column_133,
_column_134,
_column_135,
_column_136,
_column_137,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape48 remoteAlbumEntity = Shape48(
source: i0.VersionedTable(
entityName: 'remote_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_138,
_column_114,
_column_115,
_column_139,
_column_140,
_column_141,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape38 localAlbumEntity = Shape38(
source: i0.VersionedTable(
entityName: 'local_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_115,
_column_142,
_column_143,
_column_144,
_column_145,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape39 localAlbumAssetEntity = Shape39(
source: i0.VersionedTable(
entityName: 'local_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_146, _column_147, _column_145],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxLocalAlbumAssetAlbumAsset = i1.Index(
'idx_local_album_asset_album_asset',
'CREATE INDEX IF NOT EXISTS idx_local_album_asset_album_asset ON local_album_asset_entity (album_id, asset_id)',
);
final i1.Index idxLocalAssetChecksum = i1.Index(
'idx_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
);
final i1.Index idxLocalAssetCloudId = i1.Index(
'idx_local_asset_cloud_id',
'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)',
);
final i1.Index idxLocalAssetCreatedAt = i1.Index(
'idx_local_asset_created_at',
'CREATE INDEX IF NOT EXISTS idx_local_asset_created_at ON local_asset_entity (created_at)',
);
final i1.Index idxStackPrimaryAssetId = i1.Index(
'idx_stack_primary_asset_id',
'CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)',
);
final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index(
'UQ_remote_assets_owner_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)',
);
final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index(
'UQ_remote_assets_owner_library_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)',
);
final i1.Index idxRemoteAssetChecksum = i1.Index(
'idx_remote_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
);
final i1.Index idxRemoteAssetStackId = i1.Index(
'idx_remote_asset_stack_id',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)',
);
final i1.Index idxRemoteAssetOwnerVisibilityDeletedCreated = i1.Index(
'idx_remote_asset_owner_visibility_deleted_created',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created ON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC)',
);
final i1.Index idxRemoteAssetUploaded = i1.Index(
'idx_remote_asset_uploaded',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_uploaded ON remote_asset_entity (uploaded_at)',
);
late final Shape40 authUserEntity = Shape40(
source: i0.VersionedTable(
entityName: 'auth_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_109,
_column_148,
_column_110,
_column_111,
_column_149,
_column_150,
_column_151,
_column_152,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape4 userMetadataEntity = Shape4(
source: i0.VersionedTable(
entityName: 'user_metadata_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(user_id, "key")'],
columns: [_column_153, _column_154, _column_155],
attachedDatabase: database,
),
alias: null,
);
late final Shape41 partnerEntity = Shape41(
source: i0.VersionedTable(
entityName: 'partner_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
columns: [_column_156, _column_157, _column_158],
attachedDatabase: database,
),
alias: null,
);
late final Shape42 remoteExifEntity = Shape42(
source: i0.VersionedTable(
entityName: 'remote_exif_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_159,
_column_160,
_column_161,
_column_162,
_column_163,
_column_164,
_column_117,
_column_116,
_column_165,
_column_166,
_column_167,
_column_168,
_column_135,
_column_136,
_column_169,
_column_170,
_column_171,
_column_172,
_column_173,
_column_174,
_column_175,
_column_176,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape7 remoteAlbumAssetEntity = Shape7(
source: i0.VersionedTable(
entityName: 'remote_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_159, _column_177],
attachedDatabase: database,
),
alias: null,
);
late final Shape10 remoteAlbumUserEntity = Shape10(
source: i0.VersionedTable(
entityName: 'remote_album_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(album_id, user_id)'],
columns: [_column_177, _column_153, _column_178],
attachedDatabase: database,
),
alias: null,
);
late final Shape43 remoteAssetCloudIdEntity = Shape43(
source: i0.VersionedTable(
entityName: 'remote_asset_cloud_id_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_159,
_column_179,
_column_180,
_column_134,
_column_135,
_column_136,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape44 memoryEntity = Shape44(
source: i0.VersionedTable(
entityName: 'memory_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_114,
_column_115,
_column_124,
_column_121,
_column_113,
_column_181,
_column_182,
_column_183,
_column_184,
_column_185,
_column_186,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape12 memoryAssetEntity = Shape12(
source: i0.VersionedTable(
entityName: 'memory_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'],
columns: [_column_159, _column_187],
attachedDatabase: database,
),
alias: null,
);
late final Shape45 personEntity = Shape45(
source: i0.VersionedTable(
entityName: 'person_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_114,
_column_115,
_column_121,
_column_108,
_column_188,
_column_189,
_column_190,
_column_191,
_column_192,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape46 assetFaceEntity = Shape46(
source: i0.VersionedTable(
entityName: 'asset_face_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_159,
_column_193,
_column_194,
_column_195,
_column_196,
_column_197,
_column_198,
_column_199,
_column_200,
_column_201,
_column_124,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape18 storeEntity = Shape18(
source: i0.VersionedTable(
entityName: 'store_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [_column_202, _column_203, _column_204],
attachedDatabase: database,
),
alias: null,
);
late final Shape47 trashedLocalAssetEntity = Shape47(
source: i0.VersionedTable(
entityName: 'trashed_local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id, album_id)'],
columns: [
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_107,
_column_205,
_column_131,
_column_120,
_column_132,
_column_206,
_column_137,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape32 assetEditEntity = Shape32(
source: i0.VersionedTable(
entityName: 'asset_edit_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_159,
_column_207,
_column_208,
_column_209,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape49 settings = Shape49(
source: i0.VersionedTable(
entityName: 'settings',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY("key")'],
columns: [_column_210, _column_224, _column_115],
attachedDatabase: database,
),
alias: null,
);
late final Shape51 assetOcrEntity = Shape51(
source: i0.VersionedTable(
entityName: 'asset_ocr_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_159,
_column_213,
_column_214,
_column_215,
_column_216,
_column_217,
_column_218,
_column_219,
_column_220,
_column_221,
_column_222,
_column_223,
_column_201,
],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxPartnerSharedWithId = i1.Index(
'idx_partner_shared_with_id',
'CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)',
);
final i1.Index idxLatLng = i1.Index(
'idx_lat_lng',
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
);
final i1.Index idxRemoteExifCity = i1.Index(
'idx_remote_exif_city',
'CREATE INDEX IF NOT EXISTS idx_remote_exif_city ON remote_exif_entity (city) WHERE city IS NOT NULL',
);
final i1.Index idxRemoteAlbumAssetAlbumAsset = i1.Index(
'idx_remote_album_asset_album_asset',
'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)',
);
final i1.Index idxRemoteAssetCloudId = i1.Index(
'idx_remote_asset_cloud_id',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)',
);
final i1.Index idxPersonOwnerId = i1.Index(
'idx_person_owner_id',
'CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)',
);
final i1.Index idxAssetFacePersonId = i1.Index(
'idx_asset_face_person_id',
'CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)',
);
final i1.Index idxAssetFaceAssetId = i1.Index(
'idx_asset_face_asset_id',
'CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)',
);
final i1.Index idxAssetFaceVisiblePerson = i1.Index(
'idx_asset_face_visible_person',
'CREATE INDEX IF NOT EXISTS idx_asset_face_visible_person ON asset_face_entity (person_id, asset_id) WHERE is_visible = 1 AND deleted_at IS NULL',
);
final i1.Index idxTrashedLocalAssetChecksum = i1.Index(
'idx_trashed_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)',
);
final i1.Index idxTrashedLocalAssetAlbum = i1.Index(
'idx_trashed_local_asset_album',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)',
);
final i1.Index idxAssetEditAssetId = i1.Index(
'idx_asset_edit_asset_id',
'CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)',
);
final i1.Index idxAssetOcrAssetId = i1.Index(
'idx_asset_ocr_asset_id',
'CREATE INDEX IF NOT EXISTS idx_asset_ocr_asset_id ON asset_ocr_entity (asset_id)',
);
}
i0.MigrationStepWithVersion migrationSteps({ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2, required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3, required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
@@ -16536,7 +15950,6 @@ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema28 schema) from27To28, required Future<void> Function(i1.Migrator m, Schema28 schema) from27To28,
required Future<void> Function(i1.Migrator m, Schema29 schema) from28To29, required Future<void> Function(i1.Migrator m, Schema29 schema) from28To29,
required Future<void> Function(i1.Migrator m, Schema30 schema) from29To30, required Future<void> Function(i1.Migrator m, Schema30 schema) from29To30,
required Future<void> Function(i1.Migrator m, Schema31 schema) from30To31,
}) { }) {
return (currentVersion, database) async { return (currentVersion, database) async {
switch (currentVersion) { switch (currentVersion) {
@@ -16685,11 +16098,6 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema); final migrator = i1.Migrator(database, schema);
await from29To30(migrator, schema); await from29To30(migrator, schema);
return 30; return 30;
case 30:
final schema = Schema31(database: database);
final migrator = i1.Migrator(database, schema);
await from30To31(migrator, schema);
return 31;
default: default:
throw ArgumentError.value('Unknown migration from $currentVersion'); throw ArgumentError.value('Unknown migration from $currentVersion');
} }
@@ -16726,7 +16134,6 @@ i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema28 schema) from27To28, required Future<void> Function(i1.Migrator m, Schema28 schema) from27To28,
required Future<void> Function(i1.Migrator m, Schema29 schema) from28To29, required Future<void> Function(i1.Migrator m, Schema29 schema) from28To29,
required Future<void> Function(i1.Migrator m, Schema30 schema) from29To30, required Future<void> Function(i1.Migrator m, Schema30 schema) from29To30,
required Future<void> Function(i1.Migrator m, Schema31 schema) from30To31,
}) => i0.VersionedSchema.stepByStepHelper( }) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps( step: migrationSteps(
from1To2: from1To2, from1To2: from1To2,
@@ -16758,6 +16165,5 @@ i1.OnUpgrade stepByStep({
from27To28: from27To28, from27To28: from27To28,
from28To29: from28To29, from28To29: from28To29,
from29To30: from29To30, from29To30: from29To30,
from30To31: from30To31,
), ),
); );
@@ -33,7 +33,6 @@ class StorageRepository {
return file; return file;
} }
// TODO(agg23): Unify these methods
Future<File?> getMotionFileForAsset(LocalAsset asset) async { Future<File?> getMotionFileForAsset(LocalAsset asset) async {
File? file; File? file;
final log = Logger('StorageRepository'); final log = Logger('StorageRepository');
@@ -509,21 +509,9 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
return query.map((row) => row.toDto()).get(); return query.map((row) => row.toDto()).get();
} }
/// Creates a geographic map query that can dynamically filter on changing [TimelineMapOptions] TimelineQuery map(List<String> userIds, TimelineMapOptions options, GroupAssetsBy groupBy) => (
/// (most notably the active map bounds) bucketSource: () => _watchMapBucket(userIds, options, groupBy: groupBy),
TimelineQuery geographicMap( assetSource: (offset, count) => _getMapBucketAssets(userIds, options, offset: offset, count: count),
List<String> userIds,
TimelineMapOptions Function() currentOptions,
Stream<TimelineMapOptions> optionsStream,
GroupAssetsBy groupBy,
) => (
bucketSource: () => Stream.value(currentOptions())
.followedBy(optionsStream)
.switchMap(
// Any error would kill the stream for all options; make sure the stream stays alive
(options) => _watchMapBucket(userIds, options, groupBy: groupBy).handleError((_) {}),
),
assetSource: (offset, count) => _getMapBucketAssets(userIds, currentOptions(), offset: offset, count: count),
origin: TimelineOrigin.map, origin: TimelineOrigin.map,
); );
@@ -626,7 +614,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
return ( return (
bucketSource: () => _watchRemoteBucket(filter: filter, groupBy: groupBy, sortBy: sortBy), bucketSource: () => _watchRemoteBucket(filter: filter, groupBy: groupBy, sortBy: sortBy),
assetSource: (offset, count) => assetSource: (offset, count) =>
_getRemoteAssets(filter: filter, offset: offset, count: count, joinLocal: joinLocal, sortBy: sortBy), _getRemoteAssets(filter: filter, offset: offset, count: count, joinLocal: joinLocal),
origin: origin, origin: origin,
); );
} }
@@ -663,7 +651,6 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
required int offset, required int offset,
required int count, required int count,
bool joinLocal = false, bool joinLocal = false,
SortAssetsBy sortBy = SortAssetsBy.taken,
}) { }) {
if (joinLocal) { if (joinLocal) {
final query = final query =
@@ -676,11 +663,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
]) ])
..addColumns([_db.localAssetEntity.id]) ..addColumns([_db.localAssetEntity.id])
..where(filter(_db.remoteAssetEntity)) ..where(filter(_db.remoteAssetEntity))
..orderBy([ ..orderBy([OrderingTerm.desc(_db.remoteAssetEntity.createdAt)])
OrderingTerm.desc(
sortBy == SortAssetsBy.uploaded ? _db.remoteAssetEntity.uploadedAt : _db.remoteAssetEntity.createdAt,
),
])
..limit(count, offset: offset); ..limit(count, offset: offset);
return query return query
@@ -689,7 +672,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
} else { } else {
final query = _db.remoteAssetEntity.select() final query = _db.remoteAssetEntity.select()
..where(filter) ..where(filter)
..orderBy([(row) => OrderingTerm.desc(sortBy == SortAssetsBy.uploaded ? row.uploadedAt : row.createdAt)]) ..orderBy([(row) => OrderingTerm.desc(row.createdAt)])
..limit(count, offset: offset); ..limit(count, offset: offset);
return query.map((row) => row.toDto()).get(); return query.map((row) => row.toDto()).get();
@@ -114,6 +114,7 @@ class _AssetPropertiesSectionState extends ConsumerState<_AssetPropertiesSection
_PropertyItem(label: 'Height', value: asset.height?.toString()), _PropertyItem(label: 'Height', value: asset.height?.toString()),
_PropertyItem(label: 'Duration', value: asset.durationMs != null ? '${asset.durationMs} ms' : null), _PropertyItem(label: 'Duration', value: asset.durationMs != null ? '${asset.durationMs} ms' : null),
_PropertyItem(label: 'Is Favorite', value: asset.isFavorite.toString()), _PropertyItem(label: 'Is Favorite', value: asset.isFavorite.toString()),
_PropertyItem(label: 'Live Photo Video ID', value: asset.livePhotoVideoId),
_PropertyItem(label: 'Is Edited', value: asset.isEdited.toString()), _PropertyItem(label: 'Is Edited', value: asset.isEdited.toString()),
]); ]);
} }
@@ -150,7 +151,6 @@ class _AssetPropertiesSectionState extends ConsumerState<_AssetPropertiesSection
_PropertyItem(label: 'Thumb Hash', value: asset.thumbHash), _PropertyItem(label: 'Thumb Hash', value: asset.thumbHash),
_PropertyItem(label: 'Visibility', value: asset.visibility.toString()), _PropertyItem(label: 'Visibility', value: asset.visibility.toString()),
_PropertyItem(label: 'Stack ID', value: asset.stackId), _PropertyItem(label: 'Stack ID', value: asset.stackId),
_PropertyItem(label: 'Live Photo Video ID', value: asset.livePhotoVideoId),
]; ];
properties.insertAll(4, additionalProps); properties.insertAll(4, additionalProps);
@@ -154,7 +154,7 @@ class _DriftEditImagePageState extends ConsumerState<DriftEditImagePage> with Ti
} }
class _AspectRatioButton extends StatelessWidget { class _AspectRatioButton extends StatelessWidget {
final CropAspectRatio ratio; final AspectRatioPreset ratio;
final bool isSelected; final bool isSelected;
final VoidCallback onPressed; final VoidCallback onPressed;
@@ -162,16 +162,15 @@ class _AspectRatioButton extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final color = isSelected ? context.primaryColor : context.themeData.iconTheme.color;
return Column( return Column(
mainAxisSize: MainAxisSize.max, mainAxisSize: MainAxisSize.max,
children: [ children: [
IconButton( IconButton(
iconSize: 36, iconSize: 36,
icon: ratio.ratio != null icon: Transform.rotate(
? _AspectRatioRect(ratio: ratio.ratio!, color: color) angle: ratio.iconRotated ? pi / 2 : 0,
: Icon(ratio.icon, color: color), child: Icon(ratio.icon, color: isSelected ? context.primaryColor : context.themeData.iconTheme.color),
),
onPressed: onPressed, onPressed: onPressed,
), ),
Text(ratio.label, style: context.textTheme.displayMedium), Text(ratio.label, style: context.textTheme.displayMedium),
@@ -180,32 +179,6 @@ class _AspectRatioButton extends StatelessWidget {
} }
} }
class _AspectRatioRect extends StatelessWidget {
final double ratio;
final Color? color;
const _AspectRatioRect({required this.ratio, required this.color});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 28,
height: 28,
child: Center(
child: AspectRatio(
aspectRatio: ratio,
child: Container(
decoration: BoxDecoration(
border: Border.all(color: color ?? Colors.transparent, width: 3),
borderRadius: const BorderRadius.all(Radius.circular(4)),
),
),
),
),
);
}
}
class _AspectRatioSelector extends ConsumerWidget { class _AspectRatioSelector extends ConsumerWidget {
const _AspectRatioSelector(); const _AspectRatioSelector();
@@ -214,16 +187,22 @@ class _AspectRatioSelector extends ConsumerWidget {
final editorState = ref.watch(editorStateProvider); final editorState = ref.watch(editorStateProvider);
final editorNotifier = ref.read(editorStateProvider.notifier); final editorNotifier = ref.read(editorStateProvider.notifier);
// the whole crop view is rotated, so we need to swap the aspect ratio when the rotation is 90 or 270 degrees
double? selectedAspectRatio = editorState.aspectRatio;
if (editorState.rotationAngle % 180 != 0 && selectedAspectRatio != null) {
selectedAspectRatio = 1 / selectedAspectRatio;
}
return SingleChildScrollView( return SingleChildScrollView(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
child: Row( child: Row(
children: aspectRatioPresets.map((entry) { children: AspectRatioPreset.values.map((entry) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0), padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: _AspectRatioButton( child: _AspectRatioButton(
ratio: entry, ratio: entry,
isSelected: editorState.aspectRatio == entry, isSelected: selectedAspectRatio == entry.ratio,
onPressed: () => editorNotifier.setAspectRatio(entry), onPressed: () => editorNotifier.setAspectRatio(entry.ratio),
), ),
); );
}).toList(), }).toList(),
@@ -378,22 +357,8 @@ class _EditorPreviewState extends ConsumerState<_EditorPreview> with TickerProvi
final editorState = ref.watch(editorStateProvider); final editorState = ref.watch(editorStateProvider);
final editorNotifier = ref.read(editorStateProvider.notifier); final editorNotifier = ref.read(editorStateProvider.notifier);
ref.listen(editorStateProvider, (previous, current) { ref.listen(editorStateProvider, (_, current) {
// Only re-apply the aspect ratio when it changes, otherwise the crop rect will shrink on every rotation cropController.aspectRatio = current.aspectRatio;
if (previous?.aspectRatio != current.aspectRatio) {
double? ratio;
ratio = switch (current.aspectRatio) {
CropAspectRatio.original => current.originalWidth / current.originalHeight,
_ => current.aspectRatio.ratio,
};
if (current.rotationAngle % 180 != 0) {
ratio = ratio != null ? 1 / ratio : null;
}
cropController.aspectRatio = ratio;
}
if (cropController.crop != current.crop) { if (cropController.crop != current.crop) {
cropController.crop = current.crop; cropController.crop = current.crop;
@@ -421,9 +386,7 @@ class _EditorPreviewState extends ConsumerState<_EditorPreview> with TickerProvi
1.0, 1.0,
1.0, 1.0,
), ),
child: AnimatedContainer( child: Container(
duration: editorState.animationDuration,
curve: Curves.easeInOut,
padding: const EdgeInsets.all(10), padding: const EdgeInsets.all(10),
width: (editorState.rotationAngle % 180 == 0) ? baseWidth : baseHeight, width: (editorState.rotationAngle % 180 == 0) ? baseWidth : baseHeight,
height: (editorState.rotationAngle % 180 == 0) ? baseHeight : baseWidth, height: (editorState.rotationAngle % 180 == 0) ? baseHeight : baseWidth,
@@ -1,6 +1,5 @@
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/aspect_ratios.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart'; import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/utils/editor.utils.dart'; import 'package:immich_mobile/utils/editor.utils.dart';
@@ -61,8 +60,13 @@ class EditorProvider extends Notifier<EditorState> {
state = state.copyWith(crop: crop, hasUnsavedEdits: true); state = state.copyWith(crop: crop, hasUnsavedEdits: true);
} }
void setAspectRatio(CropAspectRatio preset) { void setAspectRatio(double? aspectRatio) {
state = state.copyWith(aspectRatio: preset, hasUnsavedEdits: true); if (aspectRatio != null && state.rotationAngle % 180 != 0) {
// When rotated 90 or 270 degrees, swap width and height for aspect ratio calculations
aspectRatio = 1 / aspectRatio;
}
state = state.copyWith(aspectRatio: aspectRatio);
} }
void resetEdits() { void resetEdits() {
@@ -72,19 +76,19 @@ class EditorProvider extends Notifier<EditorState> {
flipHorizontal: false, flipHorizontal: false,
flipVertical: false, flipVertical: false,
crop: const Rect.fromLTRB(0, 0, 1, 1), crop: const Rect.fromLTRB(0, 0, 1, 1),
aspectRatio: CropAspectRatio.free, aspectRatio: null,
hasUnsavedEdits: true, hasUnsavedEdits: true,
); );
} }
void rotateCCW() { void rotateCCW() {
_animateRotation(state.rotationAngle - 90); _animateRotation(state.rotationAngle - 90);
state = state.copyWith(aspectRatio: state.aspectRatio.flipped, hasUnsavedEdits: true); state = state.copyWith(hasUnsavedEdits: true);
} }
void rotateCW() { void rotateCW() {
_animateRotation(state.rotationAngle + 90); _animateRotation(state.rotationAngle + 90);
state = state.copyWith(aspectRatio: state.aspectRatio.flipped, hasUnsavedEdits: true); state = state.copyWith(hasUnsavedEdits: true);
} }
void flipHorizontally() { void flipHorizontally() {
@@ -113,7 +117,7 @@ class EditorState {
final bool flipHorizontal; final bool flipHorizontal;
final bool flipVertical; final bool flipVertical;
final Rect crop; final Rect crop;
final CropAspectRatio aspectRatio; final double? aspectRatio;
final int originalWidth; final int originalWidth;
final int originalHeight; final int originalHeight;
@@ -128,7 +132,7 @@ class EditorState {
bool? flipHorizontal, bool? flipHorizontal,
bool? flipVertical, bool? flipVertical,
Rect? crop, Rect? crop,
CropAspectRatio? aspectRatio, this.aspectRatio,
int? originalWidth, int? originalWidth,
int? originalHeight, int? originalHeight,
Duration? animationDuration, Duration? animationDuration,
@@ -141,7 +145,6 @@ class EditorState {
originalWidth = originalWidth ?? 0, originalWidth = originalWidth ?? 0,
originalHeight = originalHeight ?? 0, originalHeight = originalHeight ?? 0,
crop = crop ?? const Rect.fromLTRB(0, 0, 1, 1), crop = crop ?? const Rect.fromLTRB(0, 0, 1, 1),
aspectRatio = aspectRatio ?? CropAspectRatio.free,
hasUnsavedEdits = hasUnsavedEdits ?? false; hasUnsavedEdits = hasUnsavedEdits ?? false;
EditorState copyWith({ EditorState copyWith({
@@ -149,7 +152,7 @@ class EditorState {
int? rotationAngle, int? rotationAngle,
bool? flipHorizontal, bool? flipHorizontal,
bool? flipVertical, bool? flipVertical,
CropAspectRatio? aspectRatio, double? aspectRatio = double.infinity,
int? originalWidth, int? originalWidth,
int? originalHeight, int? originalHeight,
Duration? animationDuration, Duration? animationDuration,
@@ -161,7 +164,7 @@ class EditorState {
rotationAngle: rotationAngle ?? this.rotationAngle, rotationAngle: rotationAngle ?? this.rotationAngle,
flipHorizontal: flipHorizontal ?? this.flipHorizontal, flipHorizontal: flipHorizontal ?? this.flipHorizontal,
flipVertical: flipVertical ?? this.flipVertical, flipVertical: flipVertical ?? this.flipVertical,
aspectRatio: aspectRatio ?? this.aspectRatio, aspectRatio: aspectRatio == double.infinity ? this.aspectRatio : aspectRatio,
animationDuration: animationDuration ?? this.animationDuration, animationDuration: animationDuration ?? this.animationDuration,
originalWidth: originalWidth ?? this.originalWidth, originalWidth: originalWidth ?? this.originalWidth,
originalHeight: originalHeight ?? this.originalHeight, originalHeight: originalHeight ?? this.originalHeight,
@@ -122,14 +122,9 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
); );
} }
// Attempt to retrieve LocalAsset, falling back to remote if it cannot be found if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) {
final localAsset = await _localPlaybackAsset(videoAsset); final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!;
final file = await StorageRepository().getFileForAsset(id);
if (localAsset != null) {
final file = localAsset.isMotionPhoto
? await StorageRepository().getMotionFileForAsset(localAsset)
: await StorageRepository().getFileForAsset(localAsset.id);
if (!mounted) { if (!mounted) {
return null; return null;
} }
@@ -146,13 +141,14 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
); );
} }
final remoteAsset = videoAsset as RemoteAsset; final remoteId = (videoAsset as RemoteAsset).id;
final serverEndpoint = Store.get(StoreKey.serverEndpoint); final serverEndpoint = Store.get(StoreKey.serverEndpoint);
final isOriginalVideo = ref.read(appConfigProvider).viewer.loadOriginalVideo; final isOriginalVideo = ref.read(appConfigProvider).viewer.loadOriginalVideo;
final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback'; final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback';
final String assetId = remoteAsset.livePhotoVideoId ?? remoteAsset.id; final String videoUrl = videoAsset.livePhotoVideoId != null
final String videoUrl = '$serverEndpoint/assets/$assetId/$postfixUrl'; ? '$serverEndpoint/assets/${videoAsset.livePhotoVideoId}/$postfixUrl'
: '$serverEndpoint/assets/$remoteId/$postfixUrl';
return VideoSource.init(path: videoUrl, type: VideoSourceType.network, headers: ApiService.getRequestHeaders()); return VideoSource.init(path: videoUrl, type: VideoSourceType.network, headers: ApiService.getRequestHeaders());
} catch (error) { } catch (error) {
@@ -161,43 +157,6 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
} }
} }
Future<LocalAsset?> _localPlaybackAsset(BaseAsset baseAsset) async {
if (!baseAsset.hasLocal) {
return null;
}
LocalAsset? localAsset;
if (baseAsset is LocalAsset) {
localAsset = baseAsset;
} else {
final localId = (baseAsset as RemoteAsset).localId;
localAsset = localId != null ? await ref.read(assetServiceProvider).getLocalAsset(localId) : null;
}
if (localAsset == null) {
_log.severe(
'Invariant violation: asset ${baseAsset.name} (${baseAsset.localId}) is marked `hasLocal` but local asset could not be retrieved',
);
return null;
}
// Clients (local) may not correctly recognize a given asset as a motion photo. This allows for a scenario where both remote and local
// have the same asset (hash), but only the remote properly recognizes it as a motion asset
// If this scenario occurs, fall back to using the remote asset
if (baseAsset.isMotionPhoto && !localAsset.isMotionPhoto) {
// Platform mismatch for motion photo, use remote instead
_log.warning(
'Mismatched local and remote motion states on ${baseAsset.name} (${baseAsset.localId}), local = ${localAsset.isMotionPhoto}, remote = ${baseAsset.isMotionPhoto}',
);
return null;
}
return localAsset;
}
void _onPlaybackReady() async { void _onPlaybackReady() async {
if (!mounted || !widget.isCurrent) { if (!mounted || !widget.isCurrent) {
return; return;
@@ -35,6 +35,7 @@ class _ScopedMapTimeline extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// TODO: this causes the timeline to switch to flicker to "loading" state and back. This is both janky and inefficient.
return ProviderScope( return ProviderScope(
overrides: [ overrides: [
timelineServiceProvider.overrideWith((ref) { timelineServiceProvider.overrideWith((ref) {
@@ -43,16 +44,13 @@ class _ScopedMapTimeline extends StatelessWidget {
throw Exception('User must be logged in to access archive'); throw Exception('User must be logged in to access archive');
} }
final withPartners = ref.watch(mapStateProvider.select((s) => s.withPartners)); final users = ref.watch(mapStateProvider).withPartners
final users = withPartners ? ref.watch(timelineUsersProvider).valueOrNull ?? [user.id] : [user.id]; ? ref.watch(timelineUsersProvider).valueOrNull ?? [user.id]
: [user.id];
final timelineService = ref final timelineService = ref
.watch(timelineFactoryProvider) .watch(timelineFactoryProvider)
.geographicMap( .map(users, ref.watch(mapStateProvider).toOptions());
users,
() => ref.read(mapStateProvider).toOptions(),
ref.read(mapStateProvider.notifier).optionsStream,
);
ref.onDispose(timelineService.dispose); ref.onDispose(timelineService.dispose);
return timelineService; return timelineService;
}), }),
@@ -297,7 +297,8 @@ class _AssetTypeIcons extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isLivePhoto = asset.isMotionPhoto; final remoteAsset = asset is RemoteAsset ? asset as RemoteAsset : null;
final isLivePhoto = remoteAsset?.livePhotoVideoId != null;
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -1,5 +1,3 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/events.model.dart'; import 'package:immich_mobile/domain/models/events.model.dart';
@@ -65,16 +63,11 @@ class MapState {
class MapStateNotifier extends Notifier<MapState> { class MapStateNotifier extends Notifier<MapState> {
MapStateNotifier(); MapStateNotifier();
final StreamController<TimelineMapOptions> _optionsController = StreamController.broadcast();
Stream<TimelineMapOptions> get optionsStream => _optionsController.stream;
bool setBounds(LatLngBounds bounds) { bool setBounds(LatLngBounds bounds) {
if (state.bounds == bounds) { if (state.bounds == bounds) {
return false; return false;
} }
state = state.copyWith(bounds: bounds); state = state.copyWith(bounds: bounds);
_optionsController.add(state.toOptions());
return true; return true;
} }
@@ -89,14 +82,12 @@ class MapStateNotifier extends Notifier<MapState> {
void switchFavoriteOnly(bool isFavoriteOnly) { void switchFavoriteOnly(bool isFavoriteOnly) {
ref.read(settingsProvider).write(.mapShowFavoriteOnly, isFavoriteOnly); ref.read(settingsProvider).write(.mapShowFavoriteOnly, isFavoriteOnly);
state = state.copyWith(onlyFavorites: isFavoriteOnly); state = state.copyWith(onlyFavorites: isFavoriteOnly);
_optionsController.add(state.toOptions());
EventStream.shared.emit(const MapMarkerReloadEvent()); EventStream.shared.emit(const MapMarkerReloadEvent());
} }
void switchIncludeArchived(bool isIncludeArchived) { void switchIncludeArchived(bool isIncludeArchived) {
ref.read(settingsProvider).write(.mapIncludeArchived, isIncludeArchived); ref.read(settingsProvider).write(.mapIncludeArchived, isIncludeArchived);
state = state.copyWith(includeArchived: isIncludeArchived); state = state.copyWith(includeArchived: isIncludeArchived);
_optionsController.add(state.toOptions());
EventStream.shared.emit(const MapMarkerReloadEvent()); EventStream.shared.emit(const MapMarkerReloadEvent());
} }
@@ -109,13 +100,11 @@ class MapStateNotifier extends Notifier<MapState> {
void setRelativeTime(int relativeDays) { void setRelativeTime(int relativeDays) {
ref.read(settingsProvider).write(.mapRelativeDate, relativeDays); ref.read(settingsProvider).write(.mapRelativeDate, relativeDays);
state = state.copyWith(relativeDays: relativeDays); state = state.copyWith(relativeDays: relativeDays);
_optionsController.add(state.toOptions());
EventStream.shared.emit(const MapMarkerReloadEvent()); EventStream.shared.emit(const MapMarkerReloadEvent());
} }
@override @override
MapState build() { MapState build() {
ref.onDispose(_optionsController.close);
final mapConfig = ref.read(appConfigProvider.select((config) => config.map)); final mapConfig = ref.read(appConfigProvider.select((config) => config.map));
return MapState( return MapState(
themeMode: mapConfig.themeMode, themeMode: mapConfig.themeMode,
@@ -135,7 +135,7 @@ class _SliverTimeline extends ConsumerStatefulWidget {
ConsumerState createState() => _SliverTimelineState(); ConsumerState createState() => _SliverTimelineState();
} }
class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBindingObserver { class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
late final ScrollController _scrollController; late final ScrollController _scrollController;
StreamSubscription? _eventSubscription; StreamSubscription? _eventSubscription;
@@ -153,7 +153,6 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBi
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this);
_scrollController = ScrollController(onAttach: _restoreAssetPosition); _scrollController = ScrollController(onAttach: _restoreAssetPosition);
_eventSubscription = EventStream.shared.listen(_onEvent); _eventSubscription = EventStream.shared.listen(_onEvent);
@@ -179,14 +178,17 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBi
} }
} }
// Capture iOS status bar tap
@override
void handleStatusBarTap() => _scrollToTop();
void _onEvent(Event event) { void _onEvent(Event event) {
switch (event) { switch (event) {
case ScrollToTopEvent(): case ScrollToTopEvent():
_scrollToTop(); {
final timelineState = ref.read(timelineStateProvider.notifier);
timelineState.setScrubbing(true);
_scrollController
.animateTo(0, duration: const Duration(milliseconds: 250), curve: Curves.easeInOut)
.whenComplete(() => timelineState.setScrubbing(false));
}
case ScrollToDateEvent scrollToDateEvent: case ScrollToDateEvent scrollToDateEvent:
_scrollToDate(scrollToDateEvent.date); _scrollToDate(scrollToDateEvent.date);
case TimelineReloadEvent(): case TimelineReloadEvent():
@@ -244,24 +246,11 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBi
@override @override
void dispose() { void dispose() {
WidgetsBinding.instance.removeObserver(this);
_scrollController.dispose(); _scrollController.dispose();
_eventSubscription?.cancel(); _eventSubscription?.cancel();
super.dispose(); super.dispose();
} }
void _scrollToTop() {
if (!_scrollController.hasClients) {
return;
}
final timelineState = ref.read(timelineStateProvider.notifier);
timelineState.setScrubbing(true);
_scrollController
.animateTo(0, duration: const Duration(milliseconds: 250), curve: Curves.easeInOut)
.whenComplete(() => timelineState.setScrubbing(false));
}
void _scrollToDate(DateTime date) { void _scrollToDate(DateTime date) {
final timelineState = ref.read(timelineStateProvider.notifier); final timelineState = ref.read(timelineStateProvider.notifier);
final asyncSegments = ref.read(timelineSegmentProvider); final asyncSegments = ref.read(timelineSegmentProvider);
@@ -392,9 +381,6 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBi
child: PrimaryScrollController( child: PrimaryScrollController(
controller: _scrollController, controller: _scrollController,
child: Scaffold( child: Scaffold(
// This removes the built in Scaffold `handleStatusBarTap` implementation, preventing duplicate
// events when we provide our own
primary: false,
resizeToAvoidBottomInset: false, resizeToAvoidBottomInset: false,
floatingActionButton: const DownloadStatusFloatingButton(), floatingActionButton: const DownloadStatusFloatingButton(),
body: asyncSegments.widgetWhen( body: asyncSegments.widgetWhen(
@@ -22,7 +22,6 @@ import 'package:immich_mobile/repositories/upload.repository.dart';
import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/debug_print.dart'; import 'package:immich_mobile/utils/debug_print.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:openapi/api.dart' as api;
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
final backgroundUploadServiceProvider = Provider((ref) { final backgroundUploadServiceProvider = Provider((ref) {
@@ -336,8 +335,7 @@ class BackgroundUploadService {
return null; return null;
} }
// Visibility hidden on upload to prevent the server from running regular jobs on the live photo asset final fields = {'livePhotoVideoId': livePhotoVideoId};
final fields = {'livePhotoVideoId': livePhotoVideoId, 'visibility': api.AssetVisibility.hidden.value};
final requiresWiFi = _shouldRequireWiFi(asset); final requiresWiFi = _shouldRequireWiFi(asset);
final originalFileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name; final originalFileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
@@ -4,7 +4,7 @@ import 'dart:io';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart'; import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart' hide AssetVisibility; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/network_capability_extensions.dart'; import 'package:immich_mobile/extensions/network_capability_extensions.dart';
@@ -19,7 +19,6 @@ import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/upload.repository.dart'; import 'package:immich_mobile/repositories/upload.repository.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler; import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
@@ -339,8 +338,7 @@ class ForegroundUploadService {
final livePhotoResult = await _uploadRepository.uploadFile( final livePhotoResult = await _uploadRepository.uploadFile(
file: livePhotoFile, file: livePhotoFile,
originalFileName: livePhotoTitle, originalFileName: livePhotoTitle,
// Visibility hidden on upload to prevent the server from running regular jobs on the live photo asset fields: fields,
fields: {...fields, 'visibility': AssetVisibility.hidden.value},
cancelToken: cancelToken, cancelToken: cancelToken,
onProgress: onProgress != null onProgress: onProgress != null
? (bytes, totalBytes) => onProgress(asset.localId!, livePhotoTitle, bytes, totalBytes) ? (bytes, totalBytes) => onProgress(asset.localId!, livePhotoTitle, bytes, totalBytes)
@@ -28,8 +28,8 @@ class SystemConfigIntegrityChecksumJob {
/// Percentage limit of the integrity checksum job /// Percentage limit of the integrity checksum job
/// ///
/// Minimum value: 0 /// Minimum value: 0
/// Maximum value: 1 /// Maximum value: 9007199254740991
double percentageLimit; int percentageLimit;
/// How long the integrity checksum job may run for /// How long the integrity checksum job may run for
/// ///
@@ -75,7 +75,7 @@ class SystemConfigIntegrityChecksumJob {
return SystemConfigIntegrityChecksumJob( return SystemConfigIntegrityChecksumJob(
cronExpression: mapValueOfType<String>(json, r'cronExpression')!, cronExpression: mapValueOfType<String>(json, r'cronExpression')!,
enabled: mapValueOfType<bool>(json, r'enabled')!, enabled: mapValueOfType<bool>(json, r'enabled')!,
percentageLimit: mapValueOfType<double>(json, r'percentageLimit')!, percentageLimit: mapValueOfType<int>(json, r'percentageLimit')!,
timeLimit: mapValueOfType<int>(json, r'timeLimit')!, timeLimit: mapValueOfType<int>(json, r'timeLimit')!,
); );
} }
-4
View File
@@ -34,7 +34,6 @@ import 'schema_v27.dart' as v27;
import 'schema_v28.dart' as v28; import 'schema_v28.dart' as v28;
import 'schema_v29.dart' as v29; import 'schema_v29.dart' as v29;
import 'schema_v30.dart' as v30; import 'schema_v30.dart' as v30;
import 'schema_v31.dart' as v31;
class GeneratedHelper implements SchemaInstantiationHelper { class GeneratedHelper implements SchemaInstantiationHelper {
@override @override
@@ -100,8 +99,6 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v29.DatabaseAtV29(db); return v29.DatabaseAtV29(db);
case 30: case 30:
return v30.DatabaseAtV30(db); return v30.DatabaseAtV30(db);
case 31:
return v31.DatabaseAtV31(db);
default: default:
throw MissingSchemaException(version, versions); throw MissingSchemaException(version, versions);
} }
@@ -138,6 +135,5 @@ class GeneratedHelper implements SchemaInstantiationHelper {
28, 28,
29, 29,
30, 30,
31,
]; ];
} }
File diff suppressed because it is too large Load Diff
@@ -70,36 +70,4 @@ void main() {
expect((assets.first as RemoteAsset).id, asset.id); expect((assets.first as RemoteAsset).id, asset.id);
}); });
}); });
group('live photos', () {
test('remote-only live photo contains livePhotoVideoId and is marked as a motion photo', () async {
final user = await ctx.newUser();
final asset = await ctx.newRemoteAsset(ownerId: user.id, livePhotoVideoId: 'motion-photo-1');
final assets = await sut.main([user.id], .day).assetSource(0, 10);
expect(assets, hasLength(1));
final remote = assets.single as RemoteAsset;
expect(remote.id, asset.id);
expect(remote.livePhotoVideoId, 'motion-photo-1');
expect(remote.isMotionPhoto, isTrue);
expect(remote.localId, isNull);
});
test('merged live photo resolves localId and is marked as a motion photo', () async {
final user = await ctx.newUser();
const checksum = 'shared-live-photo-checksum';
final asset = await ctx.newRemoteAsset(ownerId: user.id, checksum: checksum, livePhotoVideoId: 'motion-photo-2');
final local = await ctx.newLocalAsset(checksum: checksum);
final assets = await sut.main([user.id], .day).assetSource(0, 10);
expect(assets, hasLength(1));
final remote = assets.single as RemoteAsset;
expect(remote.id, asset.id);
expect(remote.livePhotoVideoId, 'motion-photo-2');
expect(remote.isMotionPhoto, isTrue);
expect(remote.localId, local.id);
});
});
} }
@@ -140,7 +140,6 @@ void main() {
expect(task, isNotNull); expect(task, isNotNull);
expect(task!.fields['filename'], equals('OriginalLivePhoto.HEIC')); expect(task!.fields['filename'], equals('OriginalLivePhoto.HEIC'));
expect(task.fields['livePhotoVideoId'], equals('video-id-123')); expect(task.fields['livePhotoVideoId'], equals('video-id-123'));
expect(task.fields['visibility'], equals('hidden'));
verify(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).called(1); verify(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).called(1);
}); });
@@ -334,7 +333,6 @@ void main() {
expect(task, isNotNull); expect(task, isNotNull);
expect(task!.fields.containsKey('metadata'), isTrue); expect(task!.fields.containsKey('metadata'), isTrue);
expect(task.fields['livePhotoVideoId'], equals('video-123')); expect(task.fields['livePhotoVideoId'], equals('video-123'));
expect(task.fields['visibility'], equals('hidden'));
final metadata = jsonDecode(task.fields['metadata']!) as List; final metadata = jsonDecode(task.fields['metadata']!) as List;
expect(metadata, hasLength(1)); expect(metadata, hasLength(1));
+2 -3
View File
@@ -25917,10 +25917,9 @@
}, },
"percentageLimit": { "percentageLimit": {
"description": "Percentage limit of the integrity checksum job", "description": "Percentage limit of the integrity checksum job",
"format": "double", "maximum": 9007199254740991,
"maximum": 1,
"minimum": 0, "minimum": 0,
"type": "number" "type": "integer"
}, },
"timeLimit": { "timeLimit": {
"description": "How long the integrity checksum job may run for", "description": "How long the integrity checksum job may run for",
+3 -3
View File
@@ -151,14 +151,14 @@ const methods = wrapper<Manifest>({
}), }),
webhook: ({ config, data, functions, type, trigger }) => { webhook: ({ config, data, functions, type, trigger }) => {
const headers: Record<string, string> = {}; const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (config.headerName && config.headerValue) { if (config.headerName && config.headerValue) {
headers[config.headerName] = config.headerValue; headers[config.headerName] = config.headerValue;
} }
headers['Content-Type'] = 'application/json';
functions.httpRequest(config.url, { functions.httpRequest(config.url, {
method: config.method ?? 'POST', method: config.method ?? 'POST',
body: JSON.stringify({ body: JSON.stringify({
+1 -6
View File
@@ -74,12 +74,7 @@ const SystemConfigIntegrityJobSchema = z
const SystemConfigIntegrityChecksumJobSchema = SystemConfigIntegrityJobSchema.extend({ const SystemConfigIntegrityChecksumJobSchema = SystemConfigIntegrityJobSchema.extend({
timeLimit: z.int().nonnegative().describe('How long the integrity checksum job may run for'), timeLimit: z.int().nonnegative().describe('How long the integrity checksum job may run for'),
percentageLimit: z percentageLimit: z.int().nonnegative().describe('Percentage limit of the integrity checksum job'),
.float32()
.nonnegative()
.max(1)
.describe('Percentage limit of the integrity checksum job')
.meta({ format: 'double' }),
}) })
.describe('Integrity checksum job config') .describe('Integrity checksum job config')
.meta({ id: 'SystemConfigIntegrityChecksumJob' }); .meta({ id: 'SystemConfigIntegrityChecksumJob' });
+3 -2
View File
@@ -130,9 +130,10 @@ from
where where
"asset"."deletedAt" is null "asset"."deletedAt" is null
and "asset"."isExternal" = false and "asset"."isExternal" = false
and "asset"."createdAt" >= $2 and "integrity_report"."createdAt" >= $2
and "integrity_report"."createdAt" <= $3
order by order by
"asset"."createdAt" asc "integrity_report"."createdAt" asc
-- IntegrityRepository.streamIntegrityReports -- IntegrityRepository.streamIntegrityReports
select select
@@ -160,8 +160,8 @@ export class IntegrityRepository {
>; >;
} }
@GenerateSql({ params: [DummyValue.DATE], stream: true }) @GenerateSql({ params: [DummyValue.DATE, DummyValue.DATE], stream: true })
streamAssetChecksums(startMarker?: Date) { streamAssetChecksums(startMarker?: Date, endMarker?: Date) {
return this.db return this.db
.selectFrom('asset') .selectFrom('asset')
.where('asset.deletedAt', 'is', null) .where('asset.deletedAt', 'is', null)
@@ -178,8 +178,9 @@ export class IntegrityRepository {
'integrity_report.id as reportId', 'integrity_report.id as reportId',
]) ])
.where('asset.isExternal', '=', sql.lit(false)) .where('asset.isExternal', '=', sql.lit(false))
.$if(startMarker !== undefined, (qb) => qb.where('asset.createdAt', '>=', startMarker!)) .$if(startMarker !== undefined, (qb) => qb.where('integrity_report.createdAt', '>=', startMarker!))
.orderBy('asset.createdAt', 'asc') .$if(endMarker !== undefined, (qb) => qb.where('integrity_report.createdAt', '<=', endMarker!))
.orderBy('integrity_report.createdAt', 'asc')
.stream(); .stream();
} }
+1 -1
View File
@@ -426,7 +426,7 @@ export class AuthService extends BaseService {
throw new BadRequestException('This OAuth account has already been linked to another user.'); throw new BadRequestException('This OAuth account has already been linked to another user.');
} }
if (auth.session && sid) { if (auth.session) {
await this.sessionRepository.update(auth.session.id, { oauthSid: sid }); await this.sessionRepository.update(auth.session.id, { oauthSid: sid });
} }
+1 -67
View File
@@ -407,7 +407,7 @@ export class DatabaseBackupService {
progressCb?.('restore', progress); progressCb?.('restore', progress);
}); });
await pipeline(sqlStream, createSqlOwnerTransformStream(databaseUsername), progressSource, psql, progressSink); await pipeline(sqlStream, progressSource, psql, progressSink);
try { try {
progressCb?.('migrations', 0.9); progressCb?.('migrations', 0.9);
@@ -572,69 +572,3 @@ function createSqlProgressStreams(cb: (progress: number) => void) {
return [source, sink]; return [source, sink];
} }
function createSqlOwnerTransformStream(databaseUsername: string) {
const OWNER_MARKER_START = new TextEncoder().encode('OWNER TO ');
const DATA_MARKER_START = new TextEncoder().encode('FROM stdin');
const LINE_END = new TextEncoder().encode(';');
const owner = new TextEncoder().encode(databaseUsername);
let ownerSequenceIndex = 0;
let replacingOwnerIndex = 0;
let replacingOwner = false;
let readingDataIndex = 0;
let dataPart = false;
return new PassThrough({
transform(chunk, _encoding, callback) {
let result = chunk;
if (!dataPart) {
for (let index = 0; index < result.length; index++) {
if (replacingOwner) {
if (result[index] === LINE_END[0]) {
result = Buffer.concat([result.slice(0, index), owner.slice(replacingOwnerIndex), result.slice(index)]);
replacingOwnerIndex = owner.length;
} else {
result[index] = owner[replacingOwnerIndex];
replacingOwnerIndex++;
}
}
if (replacingOwnerIndex === owner.length) {
replacingOwner = false;
}
if (result[index] === OWNER_MARKER_START[ownerSequenceIndex]) {
ownerSequenceIndex++;
} else {
ownerSequenceIndex = 0;
}
if (ownerSequenceIndex === OWNER_MARKER_START.length) {
ownerSequenceIndex = 0;
replacingOwner = true;
replacingOwnerIndex = 0;
}
if (result[index] === DATA_MARKER_START[readingDataIndex]) {
readingDataIndex++;
} else {
readingDataIndex = 0;
}
if (readingDataIndex === DATA_MARKER_START.length) {
dataPart = true;
break;
}
}
}
this.push(result);
callback();
},
});
}
+22 -15
View File
@@ -532,7 +532,8 @@ export class IntegrityService extends BaseService {
const { count } = await this.integrityRepository.getAssetCount(); const { count } = await this.integrityRepository.getAssetCount();
const checkpoint = await this.systemMetadataRepository.get(SystemMetadataKey.IntegrityChecksumCheckpoint); const checkpoint = await this.systemMetadataRepository.get(SystemMetadataKey.IntegrityChecksumCheckpoint);
const startMarker = checkpoint?.date ? new Date(checkpoint.date) : undefined; let startMarker: Date | undefined = checkpoint?.date ? new Date(checkpoint.date) : undefined;
let endMarker: Date | undefined;
const printStats = () => { const printStats = () => {
const averageTime = ((Date.now() - startedAt) / processed).toFixed(2); const averageTime = ((Date.now() - startedAt) / processed).toFixed(2);
@@ -545,25 +546,31 @@ export class IntegrityService extends BaseService {
let lastCreatedAt: Date | undefined; let lastCreatedAt: Date | undefined;
this.logger.log(`Processing assets from ${startMarker?.toISOString() ?? 'beginning'}`); finishEarly: do {
this.logger.log(
`Processing assets in range [${startMarker?.toISOString() ?? 'beginning'}, ${endMarker?.toISOString() ?? 'end'}]`,
);
const assets = this.integrityRepository.streamAssetChecksums(startMarker); const assets = this.integrityRepository.streamAssetChecksums(startMarker, endMarker);
endMarker = startMarker;
startMarker = undefined;
for await (const { originalPath, checksum, createdAt, assetId, reportId } of assets) { for await (const { originalPath, checksum, createdAt, assetId, reportId } of assets) {
await this.checkAssetChecksum(originalPath, checksum, assetId, reportId); await this.checkAssetChecksum(originalPath, checksum, assetId, reportId);
processed++; processed++;
if (processed % 100 === 0) { if (processed % 100 === 0) {
printStats(); printStats();
}
if (Date.now() > startedAt + timeLimit || processed > count * percentageLimit) {
this.logger.log('Reached stop criteria.');
lastCreatedAt = createdAt;
break finishEarly;
}
} }
} while (endMarker);
if (Date.now() > startedAt + timeLimit || processed > count * percentageLimit) {
this.logger.log('Reached stop criteria.');
lastCreatedAt = createdAt;
break;
}
}
await this.systemMetadataRepository.set(SystemMetadataKey.IntegrityChecksumCheckpoint, { await this.systemMetadataRepository.set(SystemMetadataKey.IntegrityChecksumCheckpoint, {
date: lastCreatedAt?.toISOString(), date: lastCreatedAt?.toISOString(),
@@ -1,10 +1,9 @@
import { Kysely } from 'kysely'; import { Kysely } from 'kysely';
import { DateTime } from 'luxon';
import { createHash, randomUUID } from 'node:crypto'; import { createHash, randomUUID } from 'node:crypto';
import { Readable } from 'node:stream'; import { Readable } from 'node:stream';
import { text } from 'node:stream/consumers'; import { text } from 'node:stream/consumers';
import { StorageCore } from 'src/cores/storage.core'; import { StorageCore } from 'src/cores/storage.core';
import { AssetFileType, IntegrityReport, JobName, JobStatus, SystemMetadataKey } from 'src/enum'; import { AssetFileType, IntegrityReport, JobName, JobStatus } from 'src/enum';
import { AssetRepository } from 'src/repositories/asset.repository'; import { AssetRepository } from 'src/repositories/asset.repository';
import { ConfigRepository } from 'src/repositories/config.repository'; import { ConfigRepository } from 'src/repositories/config.repository';
import { EventRepository } from 'src/repositories/event.repository'; import { EventRepository } from 'src/repositories/event.repository';
@@ -703,30 +702,6 @@ describe(IntegrityService.name, () => {
ctx.get(IntegrityRepository).getIntegrityReport({ limit: 100 }, IntegrityReport.ChecksumFail), ctx.get(IntegrityRepository).getIntegrityReport({ limit: 100 }, IntegrityReport.ChecksumFail),
).resolves.toEqual({ items: [], nextCursor: undefined }); ).resolves.toEqual({ items: [], nextCursor: undefined });
}); });
it('should continue from checkpoint', async () => {
const { sut, ctx } = setup();
const job = ctx.getMock(JobRepository);
job.queue.mockResolvedValue();
const { user } = await ctx.newUser();
await ctx.newAsset({
ownerId: user.id,
createdAt: DateTime.now().minus({ days: 1 }).toISO(),
originalPath: '/foo/bar',
});
await ctx.newAsset({ ownerId: user.id, originalPath: '/foo/baz' });
await ctx
.get(SystemMetadataRepository)
.set(SystemMetadataKey.IntegrityChecksumCheckpoint, { date: DateTime.now().minus({ minutes: 5 }).toISO() });
await sut.handleChecksumFiles({ refreshOnly: false });
expect(ctx.getMock(StorageRepository).createPlainReadStream).not.toHaveBeenCalledWith('/foo/bar');
expect(ctx.getMock(StorageRepository).createPlainReadStream).toHaveBeenCalledWith('/foo/baz');
});
}); });
describe('handleChecksumRefresh', () => { describe('handleChecksumRefresh', () => {
@@ -3,7 +3,7 @@ import { lang } from '$lib/stores/preferences.store';
import { langs } from '$lib/utils/i18n'; import { langs } from '$lib/utils/i18n';
class LanguageManager { class LanguageManager {
constructor() { init() {
eventManager.on({ eventManager.on({
AppInit: () => lang.subscribe((lang) => this.setLanguage(lang)), AppInit: () => lang.subscribe((lang) => this.setLanguage(lang)),
}); });
@@ -15,12 +15,12 @@ import {
fromTimelinePlainDate, fromTimelinePlainDate,
fromTimelinePlainDateTime, fromTimelinePlainDateTime,
fromTimelinePlainYearMonth, fromTimelinePlainYearMonth,
fromISODateTimeUTCToObject,
getTimes, getTimes,
setDifference, setDifference,
type TimelineDateTime, type TimelineDateTime,
type TimelineYearMonth, type TimelineYearMonth,
getOrderingDate, getOrderingDate,
fromISODateTimeUTC,
} from '$lib/utils/timeline-util'; } from '$lib/utils/timeline-util';
import { GroupInsertionCache } from './group-insertion-cache.svelte'; import { GroupInsertionCache } from './group-insertion-cache.svelte';
import { TimelineDay } from './timeline-day.svelte'; import { TimelineDay } from './timeline-day.svelte';
@@ -190,7 +190,7 @@ export class TimelineMonth {
isVideo: !bucketAssets.isImage[i], isVideo: !bucketAssets.isImage[i],
livePhotoVideoId: bucketAssets.livePhotoVideoId[i], livePhotoVideoId: bucketAssets.livePhotoVideoId[i],
localDateTime, localDateTime,
createdAt: fromISODateTimeUTC(bucketAssets.createdAt[i]).toLocal().toObject(), createdAt: fromISODateTimeUTCToObject(bucketAssets.createdAt[i]),
fileCreatedAt, fileCreatedAt,
ownerId: bucketAssets.ownerId[i], ownerId: bucketAssets.ownerId[i],
projectionType: bucketAssets.projectionType[i], projectionType: bucketAssets.projectionType[i],
+2
View File
@@ -1,5 +1,6 @@
import { commandPaletteManager } from '@immich/ui'; import { commandPaletteManager } from '@immich/ui';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { languageManager } from '$lib/managers/language-manager.svelte';
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte'; import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
import { maintenanceCreateUrl, maintenanceReturnUrl, maintenanceShouldRedirect } from '$lib/utils/maintenance'; import { maintenanceCreateUrl, maintenanceReturnUrl, maintenanceShouldRedirect } from '$lib/utils/maintenance';
import { init } from '$lib/utils/server'; import { init } from '$lib/utils/server';
@@ -23,6 +24,7 @@ export const load = (async ({ fetch, url }) => {
} }
commandPaletteManager.enable(); commandPaletteManager.enable();
languageManager.init();
return { return {
error, error,