Compare commits

..

26 Commits

Author SHA1 Message Date
Daniel Dietzler 7a9b9e01be feat: user agnostic database restore (#29512) 2026-07-07 21:16:48 +02:00
Daniel Dietzler 0cbe07c4d4 fix: integrity checks dto validation (#29621) 2026-07-07 20:49:26 +02:00
renovate[bot] 45ea248e8b chore(deps): update github-actions (#29677) 2026-07-07 19:56:57 +02:00
Adam Gastineau 8cbf0db3b3 fix(mobile): disable broken Xcode queue debugging (#29657) 2026-07-07 12:26:19 -05:00
Adam Gastineau a225c962de fix(mobile): properly handle live photos locally (#29462)
* fix(mobile): properly handle live photos locally

* Added basic motion photo data tests

* Cover mismatched platforms and motion photos

* Revert unnecessary storage.repository changes

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-07-07 12:25:43 -05:00
Mees Frensel daabab8c1c fix(docs): update developer setup (#29661) 2026-07-07 17:07:35 +00:00
Brandon Wees a4f28dafb2 fix(mobile): missing crop options and rotation scaling (#29645)
* fix: missing aspect ratio options in mobile editor

* fix: smooth rotation

* chore: comments

* chore: better preset management and tooling

* lint

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-07-07 12:05:50 -05:00
Daniel Dietzler 704b287bba fix: integrity checks checksum pagination (#29516) 2026-07-07 18:42:14 +02:00
Ben Beckford c7b4a798cb fix: incorrect grouping of recently added assets (#29666)
* fix: incorrect grouping of recently added assets

* chore(web): lint

* chore(web): refactor timeline utc to local dates

* chore(mobile): index remote asset upload dates

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-07-07 11:21:58 -05:00
Daniel Dietzler f8e78792b8 fix: oauth account linking (#29624) 2026-07-07 11:21:27 -05:00
shenlong 0ebb723b39 fix: wrap migrations in transaction (#29664)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-07-07 10:54:24 -05:00
Adam Gastineau 99a8354145 fix(mobile): match iOS scroll to top and tabbar scroll (#28469) (#29705)
Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-07-07 10:53:35 -05:00
shenlong 7ee2f3644f fix(mobile): mark Live Photo motion part hidden on upload (#29699)
* fix(mobile): mark Live Photo motion part hidden on upload

* review suggestion

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-07-07 09:27:39 -05:00
bo0tzz d2a46ade78 chore: webhook headers ordering (#29695) 2026-07-07 12:09:57 +00:00
Mert 68b04e8e8f chore: simplify hls description (#29680)
simplify hls description
2026-07-07 07:45:40 +00:00
Santo Shakil 58b1c5e8fb fix(mobile): fix 10-bit heic and avif colors on android (#29631)
* fix(mobile): fix 10-bit heic and avif colors on android

* fix(mobile): share the decode pool across flutter engines

* vectorize conversion

---------

Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
2026-07-06 21:16:09 +00:00
Matthew Momjian 6f45521610 fix(docs): clarify version policy (#29654)
* version policy

* Update docs/docs/install/upgrading.md

Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>

---------

Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
2026-07-06 19:07:31 +00:00
Mert 69d568f289 feat(server): hls variant configuration (#29613)
* configure variants

* openapi

* dynamic level

* unnecessary width/height fields

* add web config

* allow 1440p and 2160p

* more helpful descriptions

* simplify level picker

* formatting

* integer instead of number
2026-07-06 14:57:48 -04:00
Daniel Dietzler a6f0253544 chore: improve added to album messages (#29641) 2026-07-06 17:21:49 +02:00
Jins K Joy 32352fa375 feat(docs): add Malayalam language support in README files (#29593) 2026-07-06 17:15:00 +02:00
Tom B 6aa5651032 fix(docs): use correct heading name for DB dump (#29483)
match docs to actual UI copy
2026-07-06 10:13:22 -05:00
Daniel Dietzler 4e25db2989 fix: chinese browser locales recognition (#29622) 2026-07-06 09:42:47 -05:00
Daniel Dietzler 4f5e0d3505 chore: clearer album users dropdown (#29629) 2026-07-06 09:40:26 -05:00
Alex b3abe0bb5d chore: keep selection state after sharing action (#29640) 2026-07-06 14:40:18 +00:00
renovate[bot] c04acc8951 chore(deps): update base-image to v202607061334 (#29634) 2026-07-06 16:01:22 +02:00
renovate[bot] af32a8ca7c chore(deps): update github-actions (#29400) 2026-07-06 15:25:52 +02:00
97 changed files with 15626 additions and 497 deletions
+4 -4
View File
@@ -103,14 +103,14 @@ jobs:
working-directory: ./mobile
run: printf "%s" $KEY_JKS | base64 -d > android/key.jks
- uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
- uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
with:
distribution: 'zulu'
java-version: '17'
- name: Restore Gradle Cache
id: cache-gradle-restore
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
with:
path: |
~/.gradle/caches
@@ -181,7 +181,7 @@ jobs:
- name: Save Gradle Cache
id: cache-gradle-save
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
if: github.ref == 'refs/heads/main'
with:
path: |
@@ -237,7 +237,7 @@ jobs:
run: flutter build ios --config-only --no-codesign
- name: Setup Ruby
uses: ruby/setup-ruby@89f90524b88a01fe6e0b732220432cc6142926af # v1.313.0
uses: ruby/setup-ruby@0dafeac902942906541bc140009cdbf32665b601 # v1.315.0
with:
ruby-version: '3.3'
bundler-cache: true
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
persist-credentials: false
- name: Check for breaking API changes
uses: oasdiff/oasdiff-action/breaking@e24529087d93f837b28b50bb66ba9016380a7fcc # v0.1.2
uses: oasdiff/oasdiff-action/breaking@ae5ef6402ebe218ac6e872c59d0fa4557694e75c # v0.1.4
with:
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
revision: open-api/immich-openapi-specs.json
+5 -5
View File
@@ -75,13 +75,13 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Set up QEMU
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Login to GitHub Container Registry
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
if: ${{ !github.event.pull_request.head.repo.fork }}
with:
registry: ghcr.io
@@ -96,7 +96,7 @@ jobs:
- name: Generate docker image tags
id: metadata
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
flavor: |
latest=false
@@ -107,7 +107,7 @@ jobs:
type=raw,value=latest,enable=${{ github.event_name == 'release' && !github.event.release.prerelease }}
- name: Build and push image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
file: packages/cli/Dockerfile
platforms: linux/amd64,linux/arm64
+3 -3
View File
@@ -57,7 +57,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
uses: github/codeql-action/init@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -70,7 +70,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
uses: github/codeql-action/autobuild@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
# ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -83,6 +83,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
with:
category: '/language:${{matrix.language}}'
+2 -2
View File
@@ -60,7 +60,7 @@ jobs:
suffix: ['', '-cuda', '-rocm', '-openvino', '-armnn', '-rknn']
steps:
- name: Login to GitHub Container Registry
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -90,7 +90,7 @@ jobs:
suffix: ['']
steps:
- name: Login to GitHub Container Registry
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
+1 -1
View File
@@ -149,7 +149,7 @@ jobs:
github-token: ${{ steps.generate-token.outputs.token }}
- name: Create draft release
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
with:
draft: true
prerelease: ${{ needs.bump_version.outputs.rc }}
+1
View File
@@ -37,6 +37,7 @@
<a href="readme_i18n/README_ar_JO.md">العربية</a>
<a href="readme_i18n/README_vi_VN.md">Tiếng Việt</a>
<a href="readme_i18n/README_th_TH.md">ภาษาไทย</a>
<a href="readme_i18n/README_ml_IN.md">മലയാളം</a>
</p>
@@ -31,7 +31,7 @@ You can trigger a database backup manually:
1. Go to **Administration > Job Queues**
2. Click **Create job** in the top right
3. Select **Create Database Backup** and click **Confirm**
3. Select **Create Database Dump** and click **Confirm**
The backup will appear in `UPLOAD_LOCATION/backups` and counts toward your retention limit.
+3 -3
View File
@@ -30,7 +30,7 @@ This environment includes the services below. Additional details are available i
- Redis
- PostgreSQL development database with exposed port `5432` so you can use any database client to access it
All the services are packaged to run as with single Docker Compose command.
All the services are packaged to run with a single Docker Compose command.
:::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`.
@@ -41,7 +41,7 @@ All the services are packaged to run as with single Docker Compose command.
1. Clone the project repo.
2. Run `cp docker/example.env docker/.env`.
3. Edit `docker/.env` to provide values for the required variable `UPLOAD_LOCATION`.
4. Install dependencies - `pnpm i`
4. Install dependencies - `mise x -- pnpm i`
5. From the root directory, run:
```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.
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`
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`
**Notes:**
+8 -4
View File
@@ -28,11 +28,15 @@ docker image prune
## Versioning Policy
Immich follows [semantic versioning][semver], which tags releases in the format `<major>.<minor>.<patch>`. We intend for breaking changes to be limited to major version releases.
You can configure your Docker image to point to the current major version by using a metatag, such as `:v3`.
Immich follows [semantic versioning][semver], which tags releases in the format `<major>.<minor>.<patch>`.
We intend for breaking changes, including those to the API or deployment, to be limited to major version releases.
You can configure your Docker image to point to the current major version by using a metatag, such as `:v3`. These metatags do not follow release candidates.
Currently, we have no plans to backport patches to earlier versions. We encourage all users to run the most recent release of Immich.
Switching back to an earlier version, even within the same minor release tag, is not supported.
The mobile app is typically compatible with the current and prior major version. However, the server is only compatible with the matching major version.
Thus, we recommend upgrading all mobile clients before upgrading the server to ensure compatibility.
We do not backport patches to earlier versions. We encourage all users to run the most recent stable release of Immich.
Downgrading to an earlier version, even within the same minor version, is not supported.
[semver]: https://semver.org/
+5
View File
@@ -431,6 +431,10 @@
"transcoding_realtime_description": "Allows transcoding to be performed in real-time as the video is being streamed. Enables quality switching, but may cause higher playback latency and stuttering depending on server capabilities.",
"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_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_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_reference_frames": "Reference frames",
"transcoding_reference_frames_description": "The number of frames to reference when compressing a given frame. Higher values improve compression efficiency, but slow down encoding. 0 sets this value automatically.",
"transcoding_required_description": "Only videos not in an accepted format",
@@ -593,6 +597,7 @@
"asset_viewer_settings_title": "Asset Viewer",
"assets": "Assets",
"assets_added_to_album_count": "Added {count, plural, one {# asset} other {# assets}} to the album",
"assets_added_to_album_partial_count": "Added {successCount} out of {totalCount} {totalCount, plural, one {asset} other {assets}} to the album",
"assets_added_to_albums_count": "Added {assetTotal, plural, one {# asset} other {# assets}} to {albumTotal, plural, one {# album} other {# albums}}",
"assets_cannot_be_added_to_album_count": "{count, plural, one {Asset} other {Assets}} cannot be added to the album",
"assets_cannot_be_added_to_albums": "{count, plural, one {Asset} other {Assets}} cannot be added to any of the albums",
@@ -107,3 +107,67 @@ Java_app_alextran_immich_NativeImage_rotate(
}
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,4 +16,14 @@ object NativeImage {
*/
@JvmStatic
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,22 +46,47 @@ inline fun ImageDecoder.Source.decodeBitmap(target: Size = Size(0, 0)): Bitmap {
}
fun Bitmap.toNativeBuffer(): Map<String, Long> {
val size = width * height * 4
// Dart reads the buffer as rgba8888, but 10-bit sources decode to RGBA_1010102, which garbles
// 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)
try {
val buffer = NativeBuffer.wrap(pointer, size)
copyPixelsToBuffer(buffer)
bitmap.copyPixelsToBuffer(buffer)
return mapOf(
"pointer" to pointer,
"width" to width.toLong(),
"height" to height.toLong(),
"rowBytes" to (width * 4).toLong()
"width" to bitmap.width.toLong(),
"height" to bitmap.height.toLong(),
"rowBytes" to (bitmap.width * 4).toLong()
)
} catch (e: Exception) {
} catch (e: Throwable) {
NativeBuffer.free(pointer)
throw e
} finally {
recycle()
bitmap.recycle()
}
}
@@ -1,6 +1,8 @@
package app.alextran.immich.images
import android.content.Context
import android.graphics.ImageDecoder
import android.os.Build
import android.os.CancellationSignal
import android.os.OperationCanceledException
import app.alextran.immich.INITIAL_BUFFER_SIZE
@@ -22,6 +24,7 @@ import java.io.File
import java.io.IOException
import java.nio.ByteBuffer
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
private const val MAX_PREALLOC_BYTES = 128 * 1024 * 1024
@@ -36,12 +39,16 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
companion object {
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(
url: String,
requestId: Long,
@Suppress("UNUSED_PARAMETER") preferEncoded: Boolean, // always returns encoded; setting has no effect on Android
preferEncoded: Boolean,
callback: (Result<Map<String, Long>?>) -> Unit
) {
val signal = CancellationSignal()
@@ -51,12 +58,51 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
url,
signal,
onSuccess = { buffer ->
requestMap.remove(requestId)
if (signal.isCanceled) {
NativeBuffer.free(buffer.pointer)
requestMap.remove(requestId)
buffer.free()
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(
Result.success(
mapOf(
File diff suppressed because it is too large Load Diff
@@ -70,7 +70,8 @@
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
allowLocationSimulation = "YES"
queueDebuggingEnabled = "NO">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
+60 -14
View File
@@ -1,19 +1,65 @@
import 'package:flutter/material.dart';
enum AspectRatioPreset {
free(ratio: null, label: 'Free', icon: Icons.crop_free_rounded),
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);
class CropAspectRatio {
final int? numerator;
final int? denominator;
final double? ratio;
final String label;
final IconData icon;
final bool iconRotated;
final String? customLabel;
final IconData? icon;
const AspectRatioPreset({required this.ratio, required this.label, required this.icon, this.iconRotated = false});
const CropAspectRatio({this.numerator, this.denominator, this.customLabel, this.icon});
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,7 +27,6 @@ sealed class BaseAsset {
final int? height;
final int? durationMs;
final bool isFavorite;
final String? livePhotoVideoId;
final bool isEdited;
const BaseAsset({
@@ -40,32 +39,15 @@ sealed class BaseAsset {
this.height,
this.durationMs,
this.isFavorite = false,
this.livePhotoVideoId,
required this.isEdited,
});
bool get isImage => type == AssetType.image;
bool get isVideo => type == AssetType.video;
bool get isMotionPhoto => livePhotoVideoId != null;
bool get isMotionPhoto => playbackStyle == AssetPlaybackStyle.livePhoto;
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 {
final durationMs = this.durationMs;
if (durationMs != null) {
@@ -79,17 +61,6 @@ sealed class BaseAsset {
bool get isLocalOnly => storage == AssetState.local;
bool get isRemoteOnly => storage == AssetState.remote;
// Same asset even if localId is known on one side but not the other (heroTag isn't stable then)
bool isSameAsset(BaseAsset other) {
if (remoteId != null && other.remoteId != null) {
return remoteId == other.remoteId;
}
if (localId != null && other.localId != null) {
return localId == other.localId;
}
return checksum != null && checksum == other.checksum;
}
bool get isEditable => false;
// Overridden in subclasses
@@ -97,6 +68,7 @@ sealed class BaseAsset {
String? get localId;
String? get remoteId;
String get heroTag;
AssetPlaybackStyle get playbackStyle;
@override
String toString() {
@@ -25,7 +25,6 @@ class LocalAsset extends BaseAsset {
super.height,
super.durationMs,
super.isFavorite = false,
super.livePhotoVideoId,
this.orientation = 0,
required this.playbackStyle,
this.adjustmentTime,
@@ -10,6 +10,7 @@ class RemoteAsset extends BaseAsset {
final AssetVisibility visibility;
final String ownerId;
final String? stackId;
final String? livePhotoVideoId;
final DateTime? uploadedAt;
final DateTime? deletedAt;
@@ -29,7 +30,7 @@ class RemoteAsset extends BaseAsset {
super.isFavorite = false,
this.thumbHash,
this.visibility = AssetVisibility.timeline,
super.livePhotoVideoId,
this.livePhotoVideoId,
this.stackId,
required super.isEdited,
this.deletedAt,
@@ -38,6 +39,23 @@ class RemoteAsset extends BaseAsset {
@override
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
String? get remoteId => id;
@@ -90,6 +108,7 @@ class RemoteAsset extends BaseAsset {
thumbHash == other.thumbHash &&
visibility == other.visibility &&
stackId == other.stackId &&
livePhotoVideoId == other.livePhotoVideoId &&
uploadedAt == other.uploadedAt &&
deletedAt == other.deletedAt;
}
@@ -103,6 +122,7 @@ class RemoteAsset extends BaseAsset {
thumbHash.hashCode ^
visibility.hashCode ^
stackId.hashCode ^
livePhotoVideoId.hashCode ^
uploadedAt.hashCode ^
deletedAt.hashCode;
@@ -26,6 +26,10 @@ class AssetService {
return _localRepository.getByChecksum(checksum);
}
Future<LocalAsset?> getLocalAsset(String id) {
return _localRepository.get(id);
}
Future<RemoteAsset?> getRemoteAssetByChecksum(String checksum) {
return _remoteRepository.getByChecksum(checksum);
}
@@ -21,6 +21,7 @@ WHERE (library_id IS NOT NULL);
CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created
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 {
const RemoteAssetEntity();
@@ -1779,3 +1779,7 @@ i0.Index get idxRemoteAssetOwnerVisibilityDeletedCreated => i0.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)',
);
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);
// Android always returns encoded data, so we need to check for both shapes of the response.
// Android falls back to encoded data if native decoding fails, so check for both shapes of the response.
final frame = switch (info) {
{'pointer': int pointer, 'length': int length} => await _fromEncodedPlatformImage(pointer, length),
{'pointer': int pointer, 'width': int width, 'height': int height, 'rowBytes': int rowBytes} =>
@@ -120,7 +120,7 @@ class Drift extends $Drift {
}
@override
int get schemaVersion => 30;
int get schemaVersion => 31;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -128,199 +128,209 @@ class Drift extends $Drift {
// Run migration steps without foreign keys and re-enable them later
await customStatement('PRAGMA foreign_keys = OFF');
await m.runMigrationSteps(
from: from,
to: to,
steps: migrationSteps(
from1To2: (m, v2) async {
for (final entity in v2.entities) {
await m.drop(entity);
await m.create(entity);
}
},
from2To3: (m, v3) async {
// 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
await m.alterTable(TableMigration(v4.personEntity));
// asset_face_entity is added
await m.create(v4.assetFaceEntity);
},
from4To5: (m, v5) async {
await m.alterTable(
TableMigration(
v5.userEntity,
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)
await customStatement('DROP INDEX IF EXISTS UQ_remote_asset_owner_checksum');
await m.drop(v6.idxRemoteAssetOwnerChecksum);
await m.create(v6.idxRemoteAssetOwnerChecksum);
// Adds libraryId to remote_asset_entity
await m.addColumn(v6.remoteAssetEntity, v6.remoteAssetEntity.libraryId);
await m.drop(v6.uQRemoteAssetsOwnerChecksum);
await m.create(v6.uQRemoteAssetsOwnerChecksum);
await m.drop(v6.uQRemoteAssetsOwnerLibraryChecksum);
await m.create(v6.uQRemoteAssetsOwnerLibraryChecksum);
},
from6To7: (m, v7) async {
await m.createIndex(v7.idxLatLng);
},
from7To8: (m, v8) async {
await m.create(v8.storeEntity);
},
from8To9: (m, v9) async {
await m.addColumn(v9.localAlbumEntity, v9.localAlbumEntity.linkedRemoteAlbumId);
},
from9To10: (m, v10) async {
await m.createTable(v10.authUserEntity);
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_);
},
from11To12: (m, v12) async {
final localToUTCMapping = {
v12.localAssetEntity: [v12.localAssetEntity.createdAt, v12.localAssetEntity.updatedAt],
v12.localAlbumEntity: [v12.localAlbumEntity.updatedAt],
};
try {
await transaction(
() => m.runMigrationSteps(
from: from,
to: to,
steps: migrationSteps(
from1To2: (m, v2) async {
for (final entity in v2.entities) {
await m.drop(entity);
await m.create(entity);
}
},
from2To3: (m, v3) async {
// 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
await m.alterTable(TableMigration(v4.personEntity));
// asset_face_entity is added
await m.create(v4.assetFaceEntity);
},
from4To5: (m, v5) async {
await m.alterTable(
TableMigration(
v5.userEntity,
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)
await customStatement('DROP INDEX IF EXISTS UQ_remote_asset_owner_checksum');
await m.drop(v6.idxRemoteAssetOwnerChecksum);
await m.create(v6.idxRemoteAssetOwnerChecksum);
// Adds libraryId to remote_asset_entity
await m.addColumn(v6.remoteAssetEntity, v6.remoteAssetEntity.libraryId);
await m.drop(v6.uQRemoteAssetsOwnerChecksum);
await m.create(v6.uQRemoteAssetsOwnerChecksum);
await m.drop(v6.uQRemoteAssetsOwnerLibraryChecksum);
await m.create(v6.uQRemoteAssetsOwnerLibraryChecksum);
},
from6To7: (m, v7) async {
await m.createIndex(v7.idxLatLng);
},
from7To8: (m, v8) async {
await m.create(v8.storeEntity);
},
from8To9: (m, v9) async {
await m.addColumn(v9.localAlbumEntity, v9.localAlbumEntity.linkedRemoteAlbumId);
},
from9To10: (m, v10) async {
await m.createTable(v10.authUserEntity);
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_);
},
from11To12: (m, v12) async {
final localToUTCMapping = {
v12.localAssetEntity: [v12.localAssetEntity.createdAt, v12.localAssetEntity.updatedAt],
v12.localAlbumEntity: [v12.localAlbumEntity.updatedAt],
};
for (final entry in localToUTCMapping.entries) {
final table = entry.key;
await m.alterTable(
TableMigration(
table,
columnTransformer: {
for (final column in entry.value)
column: column.modify(const DateTimeModifier.utc()).strftime('%Y-%m-%dT%H:%M:%fZ'),
},
),
);
}
},
from12To13: (m, v13) async {
await m.create(v13.trashedLocalAssetEntity);
await m.createIndex(v13.idxTrashedLocalAssetChecksum);
await m.createIndex(v13.idxTrashedLocalAssetAlbum);
},
from13To14: (m, v14) async {
await m.addColumn(v14.localAssetEntity, v14.localAssetEntity.adjustmentTime);
await m.addColumn(v14.localAssetEntity, v14.localAssetEntity.latitude);
await m.addColumn(v14.localAssetEntity, v14.localAssetEntity.longitude);
},
from14To15: (m, v15) async {
await m.alterTable(
TableMigration(
v15.trashedLocalAssetEntity,
columnTransformer: {v15.trashedLocalAssetEntity.source: Constant(TrashOrigin.localSync.index)},
newColumns: [v15.trashedLocalAssetEntity.source],
),
);
},
from15To16: (m, v16) async {
// Add i_cloud_id to local and remote asset tables
await m.addColumn(v16.localAssetEntity, v16.localAssetEntity.iCloudId);
await m.createIndex(v16.idxLocalAssetCloudId);
await m.createTable(v16.remoteAssetCloudIdEntity);
},
from16To17: (m, v17) async {
await m.addColumn(v17.remoteAssetEntity, v17.remoteAssetEntity.isEdited);
},
from17To18: (m, v18) async {
await m.createIndex(v18.idxRemoteAssetCloudId);
},
from18To19: (m, v19) async {
await m.createIndex(v19.idxAssetFacePersonId);
await m.createIndex(v19.idxAssetFaceAssetId);
await m.createIndex(v19.idxLocalAlbumAssetAlbumAsset);
await m.createIndex(v19.idxPartnerSharedWithId);
await m.createIndex(v19.idxPersonOwnerId);
await m.createIndex(v19.idxRemoteAlbumOwnerId);
await m.createIndex(v19.idxRemoteAlbumAssetAlbumAsset);
await m.createIndex(v19.idxRemoteAssetStackId);
await m.createIndex(v19.idxRemoteAssetLocalDateTimeDay);
await m.createIndex(v19.idxRemoteAssetLocalDateTimeMonth);
await m.createIndex(v19.idxStackPrimaryAssetId);
},
from19To20: (m, v20) async {
await m.addColumn(v20.assetFaceEntity, v20.assetFaceEntity.isVisible);
await m.addColumn(v20.assetFaceEntity, v20.assetFaceEntity.deletedAt);
},
from20To21: (m, v21) async {
await m.addColumn(v21.localAssetEntity, v21.localAssetEntity.playbackStyle);
await m.addColumn(v21.trashedLocalAssetEntity, v21.trashedLocalAssetEntity.playbackStyle);
},
from21To22: (m, v22) async {
await m.createTable(v22.assetEditEntity);
await m.createIndex(v22.idxAssetEditAssetId);
},
from22To23: (m, v23) async {
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.trashedLocalAssetEntity,
'duration_in_seconds',
v23.trashedLocalAssetEntity.durationMs,
);
for (final entry in localToUTCMapping.entries) {
final table = entry.key;
await m.alterTable(
TableMigration(
table,
columnTransformer: {
for (final column in entry.value)
column: column.modify(const DateTimeModifier.utc()).strftime('%Y-%m-%dT%H:%M:%fZ'),
},
),
);
}
},
from12To13: (m, v13) async {
await m.create(v13.trashedLocalAssetEntity);
await m.createIndex(v13.idxTrashedLocalAssetChecksum);
await m.createIndex(v13.idxTrashedLocalAssetAlbum);
},
from13To14: (m, v14) async {
await m.addColumn(v14.localAssetEntity, v14.localAssetEntity.adjustmentTime);
await m.addColumn(v14.localAssetEntity, v14.localAssetEntity.latitude);
await m.addColumn(v14.localAssetEntity, v14.localAssetEntity.longitude);
},
from14To15: (m, v15) async {
await m.alterTable(
TableMigration(
v15.trashedLocalAssetEntity,
columnTransformer: {v15.trashedLocalAssetEntity.source: Constant(TrashOrigin.localSync.index)},
newColumns: [v15.trashedLocalAssetEntity.source],
),
);
},
from15To16: (m, v16) async {
// Add i_cloud_id to local and remote asset tables
await m.addColumn(v16.localAssetEntity, v16.localAssetEntity.iCloudId);
await m.createIndex(v16.idxLocalAssetCloudId);
await m.createTable(v16.remoteAssetCloudIdEntity);
},
from16To17: (m, v17) async {
await m.addColumn(v17.remoteAssetEntity, v17.remoteAssetEntity.isEdited);
},
from17To18: (m, v18) async {
await m.createIndex(v18.idxRemoteAssetCloudId);
},
from18To19: (m, v19) async {
await m.createIndex(v19.idxAssetFacePersonId);
await m.createIndex(v19.idxAssetFaceAssetId);
await m.createIndex(v19.idxLocalAlbumAssetAlbumAsset);
await m.createIndex(v19.idxPartnerSharedWithId);
await m.createIndex(v19.idxPersonOwnerId);
await m.createIndex(v19.idxRemoteAlbumOwnerId);
await m.createIndex(v19.idxRemoteAlbumAssetAlbumAsset);
await m.createIndex(v19.idxRemoteAssetStackId);
await m.createIndex(v19.idxRemoteAssetLocalDateTimeDay);
await m.createIndex(v19.idxRemoteAssetLocalDateTimeMonth);
await m.createIndex(v19.idxStackPrimaryAssetId);
},
from19To20: (m, v20) async {
await m.addColumn(v20.assetFaceEntity, v20.assetFaceEntity.isVisible);
await m.addColumn(v20.assetFaceEntity, v20.assetFaceEntity.deletedAt);
},
from20To21: (m, v21) async {
await m.addColumn(v21.localAssetEntity, v21.localAssetEntity.playbackStyle);
await m.addColumn(v21.trashedLocalAssetEntity, v21.trashedLocalAssetEntity.playbackStyle);
},
from21To22: (m, v22) async {
await m.createTable(v22.assetEditEntity);
await m.createIndex(v22.idxAssetEditAssetId);
},
from22To23: (m, v23) async {
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.trashedLocalAssetEntity,
'duration_in_seconds',
v23.trashedLocalAssetEntity.durationMs,
);
await localAssetEntity.update().write(
LocalAssetEntityCompanion.custom(durationMs: v23.localAssetEntity.durationMs * const Constant(1000)),
);
await remoteAssetEntity.update().write(
RemoteAssetEntityCompanion.custom(durationMs: v23.remoteAssetEntity.durationMs * const Constant(1000)),
);
await trashedLocalAssetEntity.update().write(
TrashedLocalAssetEntityCompanion.custom(
durationMs: v23.trashedLocalAssetEntity.durationMs * const Constant(1000),
),
);
},
from23To24: (m, v24) async {
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);
await customStatement('DROP INDEX IF EXISTS idx_remote_asset_owner_checksum');
await customStatement('DROP INDEX IF EXISTS idx_remote_asset_local_date_time_day');
await customStatement('DROP INDEX IF EXISTS idx_remote_asset_local_date_time_month');
await m.createIndex(v25.idxRemoteAssetOwnerVisibilityDeletedCreated);
await m.createIndex(v25.idxRemoteExifCity);
await m.createIndex(v25.idxAssetFaceVisiblePerson);
},
from25To26: (m, v26) async {
await m.addColumn(v26.remoteAssetEntity, v26.remoteAssetEntity.uploadedAt);
},
from26To27: (m, v27) async {
await customStatement('ALTER TABLE metadata RENAME TO settings');
},
from27To28: (m, v28) async {
await m.createIndex(v28.idxLocalAssetCreatedAt);
},
from28To29: (m, v29) async {
await m.createTable(v29.assetOcrEntity);
await m.createIndex(v29.idxAssetOcrAssetId);
},
from29To30: (m, v30) async {
await m.alterTable(TableMigration(v30.settings));
},
),
);
await localAssetEntity.update().write(
LocalAssetEntityCompanion.custom(durationMs: v23.localAssetEntity.durationMs * const Constant(1000)),
);
await remoteAssetEntity.update().write(
RemoteAssetEntityCompanion.custom(
durationMs: v23.remoteAssetEntity.durationMs * const Constant(1000),
),
);
await trashedLocalAssetEntity.update().write(
TrashedLocalAssetEntityCompanion.custom(
durationMs: v23.trashedLocalAssetEntity.durationMs * const Constant(1000),
),
);
},
from23To24: (m, v24) async {
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);
await customStatement('DROP INDEX IF EXISTS idx_remote_asset_owner_checksum');
await customStatement('DROP INDEX IF EXISTS idx_remote_asset_local_date_time_day');
await customStatement('DROP INDEX IF EXISTS idx_remote_asset_local_date_time_month');
await m.createIndex(v25.idxRemoteAssetOwnerVisibilityDeletedCreated);
await m.createIndex(v25.idxRemoteExifCity);
await m.createIndex(v25.idxAssetFaceVisiblePerson);
},
from25To26: (m, v26) async {
await m.addColumn(v26.remoteAssetEntity, v26.remoteAssetEntity.uploadedAt);
},
from26To27: (m, v27) async {
await customStatement('ALTER TABLE metadata RENAME TO settings');
},
from27To28: (m, v28) async {
await m.createIndex(v28.idxLocalAssetCreatedAt);
},
from28To29: (m, v29) async {
await m.createTable(v29.assetOcrEntity);
await m.createIndex(v29.idxAssetOcrAssetId);
},
from29To30: (m, v30) async {
await m.alterTable(TableMigration(v30.settings));
},
from30To31: (m, v31) async {
await m.createIndex(v31.idxRemoteAssetUploaded);
},
),
),
);
if (kDebugMode) {
// Fail if the migration broke foreign keys
final wrongFKs = await customSelect('PRAGMA foreign_key_check').get();
assert(wrongFKs.isEmpty, '${wrongFKs.map((e) => e.data)}');
if (kDebugMode) {
// Fail if the migration broke foreign keys
final wrongFKs = await customSelect('PRAGMA foreign_key_check').get();
assert(wrongFKs.isEmpty, '${wrongFKs.map((e) => e.data)}');
}
} finally {
await customStatement('PRAGMA foreign_keys = ON;');
}
await customStatement('PRAGMA foreign_keys = ON;');
await optimize();
},
beforeOpen: (details) async {
@@ -124,6 +124,7 @@ abstract class $Drift extends i0.GeneratedDatabase {
i2.idxRemoteAssetChecksum,
i2.idxRemoteAssetStackId,
i2.idxRemoteAssetOwnerVisibilityDeletedCreated,
i2.idxRemoteAssetUploaded,
authUserEntity,
userMetadataEntity,
partnerEntity,
@@ -15920,6 +15920,592 @@ i1.GeneratedColumn<String> _column_224(String aliasedName) =>
type: i1.DriftSqlType.string,
$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({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
@@ -15950,6 +16536,7 @@ i0.MigrationStepWithVersion migrationSteps({
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, Schema30 schema) from29To30,
required Future<void> Function(i1.Migrator m, Schema31 schema) from30To31,
}) {
return (currentVersion, database) async {
switch (currentVersion) {
@@ -16098,6 +16685,11 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema);
await from29To30(migrator, schema);
return 30;
case 30:
final schema = Schema31(database: database);
final migrator = i1.Migrator(database, schema);
await from30To31(migrator, schema);
return 31;
default:
throw ArgumentError.value('Unknown migration from $currentVersion');
}
@@ -16134,6 +16726,7 @@ i1.OnUpgrade stepByStep({
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, Schema30 schema) from29To30,
required Future<void> Function(i1.Migrator m, Schema31 schema) from30To31,
}) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps(
from1To2: from1To2,
@@ -16165,5 +16758,6 @@ i1.OnUpgrade stepByStep({
from27To28: from27To28,
from28To29: from28To29,
from29To30: from29To30,
from30To31: from30To31,
),
);
@@ -33,6 +33,7 @@ class StorageRepository {
return file;
}
// TODO(agg23): Unify these methods
Future<File?> getMotionFileForAsset(LocalAsset asset) async {
File? file;
final log = Logger('StorageRepository');
@@ -614,7 +614,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
return (
bucketSource: () => _watchRemoteBucket(filter: filter, groupBy: groupBy, sortBy: sortBy),
assetSource: (offset, count) =>
_getRemoteAssets(filter: filter, offset: offset, count: count, joinLocal: joinLocal),
_getRemoteAssets(filter: filter, offset: offset, count: count, joinLocal: joinLocal, sortBy: sortBy),
origin: origin,
);
}
@@ -651,6 +651,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
required int offset,
required int count,
bool joinLocal = false,
SortAssetsBy sortBy = SortAssetsBy.taken,
}) {
if (joinLocal) {
final query =
@@ -663,7 +664,11 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
])
..addColumns([_db.localAssetEntity.id])
..where(filter(_db.remoteAssetEntity))
..orderBy([OrderingTerm.desc(_db.remoteAssetEntity.createdAt)])
..orderBy([
OrderingTerm.desc(
sortBy == SortAssetsBy.uploaded ? _db.remoteAssetEntity.uploadedAt : _db.remoteAssetEntity.createdAt,
),
])
..limit(count, offset: offset);
return query
@@ -672,7 +677,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
} else {
final query = _db.remoteAssetEntity.select()
..where(filter)
..orderBy([(row) => OrderingTerm.desc(row.createdAt)])
..orderBy([(row) => OrderingTerm.desc(sortBy == SortAssetsBy.uploaded ? row.uploadedAt : row.createdAt)])
..limit(count, offset: offset);
return query.map((row) => row.toDto()).get();
@@ -114,7 +114,6 @@ class _AssetPropertiesSectionState extends ConsumerState<_AssetPropertiesSection
_PropertyItem(label: 'Height', value: asset.height?.toString()),
_PropertyItem(label: 'Duration', value: asset.durationMs != null ? '${asset.durationMs} ms' : null),
_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()),
]);
}
@@ -151,6 +150,7 @@ class _AssetPropertiesSectionState extends ConsumerState<_AssetPropertiesSection
_PropertyItem(label: 'Thumb Hash', value: asset.thumbHash),
_PropertyItem(label: 'Visibility', value: asset.visibility.toString()),
_PropertyItem(label: 'Stack ID', value: asset.stackId),
_PropertyItem(label: 'Live Photo Video ID', value: asset.livePhotoVideoId),
];
properties.insertAll(4, additionalProps);
@@ -154,7 +154,7 @@ class _DriftEditImagePageState extends ConsumerState<DriftEditImagePage> with Ti
}
class _AspectRatioButton extends StatelessWidget {
final AspectRatioPreset ratio;
final CropAspectRatio ratio;
final bool isSelected;
final VoidCallback onPressed;
@@ -162,15 +162,16 @@ class _AspectRatioButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
final color = isSelected ? context.primaryColor : context.themeData.iconTheme.color;
return Column(
mainAxisSize: MainAxisSize.max,
children: [
IconButton(
iconSize: 36,
icon: Transform.rotate(
angle: ratio.iconRotated ? pi / 2 : 0,
child: Icon(ratio.icon, color: isSelected ? context.primaryColor : context.themeData.iconTheme.color),
),
icon: ratio.ratio != null
? _AspectRatioRect(ratio: ratio.ratio!, color: color)
: Icon(ratio.icon, color: color),
onPressed: onPressed,
),
Text(ratio.label, style: context.textTheme.displayMedium),
@@ -179,6 +180,32 @@ 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 {
const _AspectRatioSelector();
@@ -187,22 +214,16 @@ class _AspectRatioSelector extends ConsumerWidget {
final editorState = ref.watch(editorStateProvider);
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(
scrollDirection: Axis.horizontal,
child: Row(
children: AspectRatioPreset.values.map((entry) {
children: aspectRatioPresets.map((entry) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: _AspectRatioButton(
ratio: entry,
isSelected: selectedAspectRatio == entry.ratio,
onPressed: () => editorNotifier.setAspectRatio(entry.ratio),
isSelected: editorState.aspectRatio == entry,
onPressed: () => editorNotifier.setAspectRatio(entry),
),
);
}).toList(),
@@ -357,8 +378,22 @@ class _EditorPreviewState extends ConsumerState<_EditorPreview> with TickerProvi
final editorState = ref.watch(editorStateProvider);
final editorNotifier = ref.read(editorStateProvider.notifier);
ref.listen(editorStateProvider, (_, current) {
cropController.aspectRatio = current.aspectRatio;
ref.listen(editorStateProvider, (previous, current) {
// Only re-apply the aspect ratio when it changes, otherwise the crop rect will shrink on every rotation
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) {
cropController.crop = current.crop;
@@ -386,7 +421,9 @@ class _EditorPreviewState extends ConsumerState<_EditorPreview> with TickerProvi
1.0,
1.0,
),
child: Container(
child: AnimatedContainer(
duration: editorState.animationDuration,
curve: Curves.easeInOut,
padding: const EdgeInsets.all(10),
width: (editorState.rotationAngle % 180 == 0) ? baseWidth : baseHeight,
height: (editorState.rotationAngle % 180 == 0) ? baseHeight : baseWidth,
@@ -1,5 +1,6 @@
import 'package:flutter/services.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/exif.model.dart';
import 'package:immich_mobile/utils/editor.utils.dart';
@@ -60,13 +61,8 @@ class EditorProvider extends Notifier<EditorState> {
state = state.copyWith(crop: crop, hasUnsavedEdits: true);
}
void setAspectRatio(double? aspectRatio) {
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 setAspectRatio(CropAspectRatio preset) {
state = state.copyWith(aspectRatio: preset, hasUnsavedEdits: true);
}
void resetEdits() {
@@ -76,19 +72,19 @@ class EditorProvider extends Notifier<EditorState> {
flipHorizontal: false,
flipVertical: false,
crop: const Rect.fromLTRB(0, 0, 1, 1),
aspectRatio: null,
aspectRatio: CropAspectRatio.free,
hasUnsavedEdits: true,
);
}
void rotateCCW() {
_animateRotation(state.rotationAngle - 90);
state = state.copyWith(hasUnsavedEdits: true);
state = state.copyWith(aspectRatio: state.aspectRatio.flipped, hasUnsavedEdits: true);
}
void rotateCW() {
_animateRotation(state.rotationAngle + 90);
state = state.copyWith(hasUnsavedEdits: true);
state = state.copyWith(aspectRatio: state.aspectRatio.flipped, hasUnsavedEdits: true);
}
void flipHorizontally() {
@@ -117,7 +113,7 @@ class EditorState {
final bool flipHorizontal;
final bool flipVertical;
final Rect crop;
final double? aspectRatio;
final CropAspectRatio aspectRatio;
final int originalWidth;
final int originalHeight;
@@ -132,7 +128,7 @@ class EditorState {
bool? flipHorizontal,
bool? flipVertical,
Rect? crop,
this.aspectRatio,
CropAspectRatio? aspectRatio,
int? originalWidth,
int? originalHeight,
Duration? animationDuration,
@@ -145,6 +141,7 @@ class EditorState {
originalWidth = originalWidth ?? 0,
originalHeight = originalHeight ?? 0,
crop = crop ?? const Rect.fromLTRB(0, 0, 1, 1),
aspectRatio = aspectRatio ?? CropAspectRatio.free,
hasUnsavedEdits = hasUnsavedEdits ?? false;
EditorState copyWith({
@@ -152,7 +149,7 @@ class EditorState {
int? rotationAngle,
bool? flipHorizontal,
bool? flipVertical,
double? aspectRatio = double.infinity,
CropAspectRatio? aspectRatio,
int? originalWidth,
int? originalHeight,
Duration? animationDuration,
@@ -164,7 +161,7 @@ class EditorState {
rotationAngle: rotationAngle ?? this.rotationAngle,
flipHorizontal: flipHorizontal ?? this.flipHorizontal,
flipVertical: flipVertical ?? this.flipVertical,
aspectRatio: aspectRatio == double.infinity ? this.aspectRatio : aspectRatio,
aspectRatio: aspectRatio ?? this.aspectRatio,
animationDuration: animationDuration ?? this.animationDuration,
originalWidth: originalWidth ?? this.originalWidth,
originalHeight: originalHeight ?? this.originalHeight,
@@ -159,8 +159,6 @@ class ShareActionButton extends ConsumerWidget {
return;
}
ref.read(multiSelectProvider.notifier).reset();
if (!result.success) {
ImmichToast.show(
context: context,
@@ -173,7 +171,6 @@ class ShareActionButton extends ConsumerWidget {
buildContext.pop();
});
// Show download progress with a "Preparing" message
return preparingDialog;
},
barrierDismissible: false,
@@ -395,7 +395,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
@override
Widget build(BuildContext context) {
final currentAsset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
final currentHeroTag = ref.watch(assetViewerProvider.select((s) => s.currentAsset?.heroTag));
_showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
final stackIndex = ref.watch(assetViewerProvider.select((s) => s.stackIndex));
final isPlayingMotionVideo = ref.watch(isPlayingMotionVideoProvider);
@@ -414,7 +414,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
displayAsset = stackChildren.elementAt(stackIndex);
}
final isCurrent = currentAsset != null && currentAsset.isSameAsset(displayAsset);
final isCurrent = currentHeroTag == displayAsset.heroTag;
final viewportWidth = MediaQuery.widthOf(context);
final viewportHeight = MediaQuery.heightOf(context);
@@ -122,9 +122,14 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
);
}
if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) {
final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!;
final file = await StorageRepository().getFileForAsset(id);
// Attempt to retrieve LocalAsset, falling back to remote if it cannot be found
final localAsset = await _localPlaybackAsset(videoAsset);
if (localAsset != null) {
final file = localAsset.isMotionPhoto
? await StorageRepository().getMotionFileForAsset(localAsset)
: await StorageRepository().getFileForAsset(localAsset.id);
if (!mounted) {
return null;
}
@@ -141,14 +146,13 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
);
}
final remoteId = (videoAsset as RemoteAsset).id;
final remoteAsset = videoAsset as RemoteAsset;
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
final isOriginalVideo = ref.read(appConfigProvider).viewer.loadOriginalVideo;
final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback';
final String videoUrl = videoAsset.livePhotoVideoId != null
? '$serverEndpoint/assets/${videoAsset.livePhotoVideoId}/$postfixUrl'
: '$serverEndpoint/assets/$remoteId/$postfixUrl';
final String assetId = remoteAsset.livePhotoVideoId ?? remoteAsset.id;
final String videoUrl = '$serverEndpoint/assets/$assetId/$postfixUrl';
return VideoSource.init(path: videoUrl, type: VideoSourceType.network, headers: ApiService.getRequestHeaders());
} catch (error) {
@@ -157,6 +161,43 @@ 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 {
if (!mounted || !widget.isCurrent) {
return;
@@ -297,8 +297,7 @@ class _AssetTypeIcons extends StatelessWidget {
@override
Widget build(BuildContext context) {
final remoteAsset = asset is RemoteAsset ? asset as RemoteAsset : null;
final isLivePhoto = remoteAsset?.livePhotoVideoId != null;
final isLivePhoto = asset.isMotionPhoto;
return Column(
mainAxisSize: MainAxisSize.min,
@@ -135,7 +135,7 @@ class _SliverTimeline extends ConsumerStatefulWidget {
ConsumerState createState() => _SliverTimelineState();
}
class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBindingObserver {
late final ScrollController _scrollController;
StreamSubscription? _eventSubscription;
@@ -153,6 +153,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_scrollController = ScrollController(onAttach: _restoreAssetPosition);
_eventSubscription = EventStream.shared.listen(_onEvent);
@@ -178,17 +179,14 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
}
}
// Capture iOS status bar tap
@override
void handleStatusBarTap() => _scrollToTop();
void _onEvent(Event event) {
switch (event) {
case ScrollToTopEvent():
{
final timelineState = ref.read(timelineStateProvider.notifier);
timelineState.setScrubbing(true);
_scrollController
.animateTo(0, duration: const Duration(milliseconds: 250), curve: Curves.easeInOut)
.whenComplete(() => timelineState.setScrubbing(false));
}
_scrollToTop();
case ScrollToDateEvent scrollToDateEvent:
_scrollToDate(scrollToDateEvent.date);
case TimelineReloadEvent():
@@ -246,11 +244,24 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_scrollController.dispose();
_eventSubscription?.cancel();
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) {
final timelineState = ref.read(timelineStateProvider.notifier);
final asyncSegments = ref.read(timelineSegmentProvider);
@@ -381,6 +392,9 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
child: PrimaryScrollController(
controller: _scrollController,
child: Scaffold(
// This removes the built in Scaffold `handleStatusBarTap` implementation, preventing duplicate
// events when we provide our own
primary: false,
resizeToAvoidBottomInset: false,
floatingActionButton: const DownloadStatusFloatingButton(),
body: asyncSegments.widgetWhen(
@@ -22,6 +22,7 @@ import 'package:immich_mobile/repositories/upload.repository.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart' as api;
import 'package:path/path.dart' as p;
final backgroundUploadServiceProvider = Provider((ref) {
@@ -335,7 +336,8 @@ class BackgroundUploadService {
return null;
}
final fields = {'livePhotoVideoId': livePhotoVideoId};
// Visibility hidden on upload to prevent the server from running regular jobs on the live photo asset
final fields = {'livePhotoVideoId': livePhotoVideoId, 'visibility': api.AssetVisibility.hidden.value};
final requiresWiFi = _shouldRequireWiFi(asset);
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:immich_mobile/domain/models/asset/asset_metadata.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart' hide AssetVisibility;
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/network_capability_extensions.dart';
@@ -19,6 +19,7 @@ import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/upload.repository.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:path/path.dart' as p;
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
@@ -338,7 +339,8 @@ class ForegroundUploadService {
final livePhotoResult = await _uploadRepository.uploadFile(
file: livePhotoFile,
originalFileName: livePhotoTitle,
fields: fields,
// Visibility hidden on upload to prevent the server from running regular jobs on the live photo asset
fields: {...fields, 'visibility': AssetVisibility.hidden.value},
cancelToken: cancelToken,
onProgress: onProgress != null
? (bytes, totalBytes) => onProgress(asset.localId!, livePhotoTitle, bytes, totalBytes)
+1
View File
@@ -452,6 +452,7 @@ Class | Method | HTTP request | Description
- [FacialRecognitionConfig](doc//FacialRecognitionConfig.md)
- [FoldersResponse](doc//FoldersResponse.md)
- [FoldersUpdate](doc//FoldersUpdate.md)
- [HlsVideoResolution](doc//HlsVideoResolution.md)
- [ImageFormat](doc//ImageFormat.md)
- [IntegrityReport](doc//IntegrityReport.md)
- [IntegrityReportResponseDto](doc//IntegrityReportResponseDto.md)
+1
View File
@@ -173,6 +173,7 @@ part 'model/face_dto.dart';
part 'model/facial_recognition_config.dart';
part 'model/folders_response.dart';
part 'model/folders_update.dart';
part 'model/hls_video_resolution.dart';
part 'model/image_format.dart';
part 'model/integrity_report.dart';
part 'model/integrity_report_response_dto.dart';
+2
View File
@@ -391,6 +391,8 @@ class ApiClient {
return FoldersResponse.fromJson(value);
case 'FoldersUpdate':
return FoldersUpdate.fromJson(value);
case 'HlsVideoResolution':
return HlsVideoResolutionTypeTransformer().decode(value);
case 'ImageFormat':
return ImageFormatTypeTransformer().decode(value);
case 'IntegrityReport':
+3
View File
@@ -106,6 +106,9 @@ String parameterToString(dynamic value) {
if (value is Colorspace) {
return ColorspaceTypeTransformer().encode(value).toString();
}
if (value is HlsVideoResolution) {
return HlsVideoResolutionTypeTransformer().encode(value).toString();
}
if (value is ImageFormat) {
return ImageFormatTypeTransformer().encode(value).toString();
}
+94
View File
@@ -0,0 +1,94 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
/// HLS video resolution
class HlsVideoResolution {
/// Instantiate a new enum with the provided [value].
const HlsVideoResolution._(this.value);
/// The underlying value of this enum member.
final int value;
@override
String toString() => value.toString();
int toJson() => value;
static const number480 = HlsVideoResolution._(480);
static const number720 = HlsVideoResolution._(720);
static const number1080 = HlsVideoResolution._(1080);
static const number1440 = HlsVideoResolution._(1440);
static const number2160 = HlsVideoResolution._(2160);
/// List of all possible values in this [enum][HlsVideoResolution].
static const values = <HlsVideoResolution>[
number480,
number720,
number1080,
number1440,
number2160,
];
static HlsVideoResolution? fromJson(dynamic value) => HlsVideoResolutionTypeTransformer().decode(value);
static List<HlsVideoResolution> listFromJson(dynamic json, {bool growable = false,}) {
final result = <HlsVideoResolution>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = HlsVideoResolution.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [HlsVideoResolution] to int,
/// and [decode] dynamic data back to [HlsVideoResolution].
class HlsVideoResolutionTypeTransformer {
factory HlsVideoResolutionTypeTransformer() => _instance ??= const HlsVideoResolutionTypeTransformer._();
const HlsVideoResolutionTypeTransformer._();
int encode(HlsVideoResolution data) => data.value;
/// Decodes a [dynamic value][data] to a HlsVideoResolution.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
HlsVideoResolution? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case 480: return HlsVideoResolution.number480;
case 720: return HlsVideoResolution.number720;
case 1080: return HlsVideoResolution.number1080;
case 1440: return HlsVideoResolution.number1440;
case 2160: return HlsVideoResolution.number2160;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [HlsVideoResolutionTypeTransformer] instance.
static HlsVideoResolutionTypeTransformer? _instance;
}
@@ -14,26 +14,40 @@ class SystemConfigFFmpegRealtimeDto {
/// Returns a new [SystemConfigFFmpegRealtimeDto] instance.
SystemConfigFFmpegRealtimeDto({
required this.enabled,
this.resolutions = const [],
this.videoCodecs = const [],
});
/// Enable real-time HLS transcoding (alpha)
bool enabled;
/// Resolutions to use for real-time HLS transcoding
List<HlsVideoResolution> resolutions;
/// Video codecs to use for real-time HLS transcoding
List<VideoCodec> videoCodecs;
@override
bool operator ==(Object other) => identical(this, other) || other is SystemConfigFFmpegRealtimeDto &&
other.enabled == enabled;
other.enabled == enabled &&
_deepEquality.equals(other.resolutions, resolutions) &&
_deepEquality.equals(other.videoCodecs, videoCodecs);
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(enabled.hashCode);
(enabled.hashCode) +
(resolutions.hashCode) +
(videoCodecs.hashCode);
@override
String toString() => 'SystemConfigFFmpegRealtimeDto[enabled=$enabled]';
String toString() => 'SystemConfigFFmpegRealtimeDto[enabled=$enabled, resolutions=$resolutions, videoCodecs=$videoCodecs]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'enabled'] = this.enabled;
json[r'resolutions'] = this.resolutions;
json[r'videoCodecs'] = this.videoCodecs;
return json;
}
@@ -47,6 +61,8 @@ class SystemConfigFFmpegRealtimeDto {
return SystemConfigFFmpegRealtimeDto(
enabled: mapValueOfType<bool>(json, r'enabled')!,
resolutions: HlsVideoResolution.listFromJson(json[r'resolutions']),
videoCodecs: VideoCodec.listFromJson(json[r'videoCodecs']),
);
}
return null;
@@ -95,6 +111,8 @@ class SystemConfigFFmpegRealtimeDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'enabled',
'resolutions',
'videoCodecs',
};
}
@@ -28,8 +28,8 @@ class SystemConfigIntegrityChecksumJob {
/// Percentage limit of the integrity checksum job
///
/// Minimum value: 0
/// Maximum value: 9007199254740991
int percentageLimit;
/// Maximum value: 1
double percentageLimit;
/// How long the integrity checksum job may run for
///
@@ -75,7 +75,7 @@ class SystemConfigIntegrityChecksumJob {
return SystemConfigIntegrityChecksumJob(
cronExpression: mapValueOfType<String>(json, r'cronExpression')!,
enabled: mapValueOfType<bool>(json, r'enabled')!,
percentageLimit: mapValueOfType<int>(json, r'percentageLimit')!,
percentageLimit: mapValueOfType<double>(json, r'percentageLimit')!,
timeLimit: mapValueOfType<int>(json, r'timeLimit')!,
);
}
@@ -1,46 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import '../../unit/factories/local_asset_factory.dart';
import '../../unit/factories/remote_asset_factory.dart';
void main() {
group('BaseAsset.isSameAsset', () {
test('search/folder copy (localId null) matches the merged DB copy (localId set)', () {
// #29472: search and folder assets arrive with localId null, then the viewer
// watches the DB copy which fills localId. heroTag embeds localId, so it
// diverges for the same asset and isCurrent used to go false.
final searchCopy = RemoteAssetFactory.create(id: 'asset-1');
final mergedCopy = searchCopy.copyWith(localId: 'local-1');
expect(searchCopy.localId, isNull);
expect(mergedCopy.localId, 'local-1');
expect(searchCopy.heroTag, isNot(mergedCopy.heroTag));
expect(searchCopy.isSameAsset(mergedCopy), isTrue);
expect(mergedCopy.isSameAsset(searchCopy), isTrue);
});
test('different remote assets are not the same', () {
final a = RemoteAssetFactory.create(id: 'asset-1');
final b = RemoteAssetFactory.create(id: 'asset-2');
expect(a.isSameAsset(b), isFalse);
});
test('same checksum but different remote ids are not the same (duplicate files)', () {
final a = RemoteAssetFactory.create(id: 'asset-1');
final b = RemoteAssetFactory.create(id: 'asset-2').copyWith(checksum: a.checksum);
expect(a.checksum, b.checksum);
expect(a.isSameAsset(b), isFalse);
});
test('falls back to checksum when only one side has an id (local-only vs remote-only)', () {
final remoteOnly = RemoteAssetFactory.create(id: 'asset-1');
final localOnly = LocalAssetFactory.create(id: 'local-1').copyWith(checksum: remoteOnly.checksum);
expect(remoteOnly.isSameAsset(localOnly), isTrue);
expect(localOnly.isSameAsset(remoteOnly), isTrue);
});
});
}
+4
View File
@@ -34,6 +34,7 @@ import 'schema_v27.dart' as v27;
import 'schema_v28.dart' as v28;
import 'schema_v29.dart' as v29;
import 'schema_v30.dart' as v30;
import 'schema_v31.dart' as v31;
class GeneratedHelper implements SchemaInstantiationHelper {
@override
@@ -99,6 +100,8 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v29.DatabaseAtV29(db);
case 30:
return v30.DatabaseAtV30(db);
case 31:
return v31.DatabaseAtV31(db);
default:
throw MissingSchemaException(version, versions);
}
@@ -135,5 +138,6 @@ class GeneratedHelper implements SchemaInstantiationHelper {
28,
29,
30,
31,
];
}
File diff suppressed because it is too large Load Diff
@@ -70,4 +70,36 @@ void main() {
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,6 +140,7 @@ void main() {
expect(task, isNotNull);
expect(task!.fields['filename'], equals('OriginalLivePhoto.HEIC'));
expect(task.fields['livePhotoVideoId'], equals('video-id-123'));
expect(task.fields['visibility'], equals('hidden'));
verify(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).called(1);
});
@@ -333,6 +334,7 @@ void main() {
expect(task, isNotNull);
expect(task!.fields.containsKey('metadata'), isTrue);
expect(task.fields['livePhotoVideoId'], equals('video-123'));
expect(task.fields['visibility'], equals('hidden'));
final metadata = jsonDecode(task.fields['metadata']!) as List;
expect(metadata, hasLength(1));
+31 -3
View File
@@ -19120,6 +19120,17 @@
},
"type": "object"
},
"HlsVideoResolution": {
"description": "HLS video resolution",
"enum": [
480,
720,
1080,
1440,
2160
],
"type": "integer"
},
"ImageFormat": {
"description": "Image format",
"enum": [
@@ -25754,10 +25765,26 @@
"enabled": {
"description": "Enable real-time HLS transcoding (alpha)",
"type": "boolean"
},
"resolutions": {
"description": "Resolutions to use for real-time HLS transcoding",
"items": {
"$ref": "#/components/schemas/HlsVideoResolution"
},
"type": "array"
},
"videoCodecs": {
"description": "Video codecs to use for real-time HLS transcoding",
"items": {
"$ref": "#/components/schemas/VideoCodec"
},
"type": "array"
}
},
"required": [
"enabled"
"enabled",
"resolutions",
"videoCodecs"
],
"type": "object"
},
@@ -25890,9 +25917,10 @@
},
"percentageLimit": {
"description": "Percentage limit of the integrity checksum job",
"maximum": 9007199254740991,
"format": "double",
"maximum": 1,
"minimum": 0,
"type": "integer"
"type": "number"
},
"timeLimit": {
"description": "How long the integrity checksum job may run for",
+3 -3
View File
@@ -151,14 +151,14 @@ const methods = wrapper<Manifest>({
}),
webhook: ({ config, data, functions, type, trigger }) => {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
const headers: Record<string, string> = {};
if (config.headerName && config.headerValue) {
headers[config.headerName] = config.headerValue;
}
headers['Content-Type'] = 'application/json';
functions.httpRequest(config.url, {
method: config.method ?? 'POST',
body: JSON.stringify({
+11
View File
@@ -2302,6 +2302,10 @@ export type SystemConfigBackupsDto = {
export type SystemConfigFFmpegRealtimeDto = {
/** Enable real-time HLS transcoding (alpha) */
enabled: boolean;
/** Resolutions to use for real-time HLS transcoding */
resolutions: HlsVideoResolution[];
/** Video codecs to use for real-time HLS transcoding */
videoCodecs: VideoCodec[];
};
export type SystemConfigFFmpegDto = {
accel: TranscodeHWAccel;
@@ -7629,6 +7633,13 @@ export enum CQMode {
Cqp = "cqp",
Icq = "icq"
}
export enum HlsVideoResolution {
$480 = 480,
$720 = 720,
$1080 = 1080,
$1440 = 1440,
$2160 = 2160
}
export enum ToneMapping {
Hable = "hable",
Mobius = "mobius",
+1
View File
@@ -35,6 +35,7 @@
<a href="README_pt_BR.md">Português Brasileiro</a>
<a href="README_sv_SE.md">Svenska</a>
<a href="README_th_TH.md">ภาษาไทย</a>
<a href="README_ml_IN.md">മലയാളം</a>
</p>
> [!WARNING]
+1
View File
@@ -35,6 +35,7 @@
<a href="README_sv_SE.md">Svenska</a>
<a href="README_ar_JO.md">العربية</a>
<a href="README_th_TH.md">ภาษาไทย</a>
<a href="README_ml_IN.md">മലയാളം</a>
</p>
> [!WARNING]
+1
View File
@@ -36,6 +36,7 @@
<a href="README_ar_JO.md">العربية</a>
<a href="README_vi_VN.md">Tiếng Việt</a>
<a href="README_th_TH.md">ภาษาไทย</a>
<a href="README_ml_IN.md">മലയാളം</a>
</p>
> [!WARNING]
+1
View File
@@ -35,6 +35,7 @@
<a href="README_sv_SE.md">Svenska</a>
<a href="README_ar_JO.md">العربية</a>
<a href="README_th_TH.md">ภาษาไทย</a>
<a href="README_ml_IN.md">മലയാളം</a>
</p>
> [!WARNING]
+1
View File
@@ -35,6 +35,7 @@
<a href="README_sv_SE.md">Svenska</a>
<a href="README_ar_JO.md">العربية</a>
<a href="README_th_TH.md">ภาษาไทย</a>
<a href="README_ml_IN.md">മലയാളം</a>
</p>
> [!WARNING]
+1
View File
@@ -36,6 +36,7 @@
<a href="README_sv_SE.md">Svenska</a>
<a href="README_ar_JO.md">العربية</a>
<a href="README_th_TH.md">ภาษาไทย</a>
<a href="README_ml_IN.md">മലയാളം</a>
</p>
> [!WARNING]
+1
View File
@@ -34,6 +34,7 @@
<a href="README_sv_SE.md">Svenska</a>
<a href="README_ar_JO.md">العربية</a>
<a href="README_th_TH.md">ภาษาไทย</a>
<a href="README_ml_IN.md">മലയാളം</a>
</p>
> [!WARNING]
+1
View File
@@ -36,6 +36,7 @@
<a href="README_sv_SE.md">Svenska</a>
<a href="README_ar_JO.md">العربية</a>
<a href="README_th_TH.md">ภาษาไทย</a>
<a href="README_ml_IN.md">മലയാളം</a>
</p>
+133
View File
@@ -0,0 +1,133 @@
<p align="center">
<br/>
<a href="https://opensource.org/license/agpl-v3"><img src="https://img.shields.io/badge/License-AGPL_v3-blue.svg?color=3F51B5&style=for-the-badge&label=License&logoColor=000000&labelColor=ececec" alt="License: AGPLv3"></a>
<a href="https://discord.immich.app">
<img src="https://img.shields.io/discord/979116623879368755.svg?label=Discord&logo=Discord&style=for-the-badge&logoColor=000000&labelColor=ececec" alt="Discord"/>
</a>
<br/>
<br/>
</p>
<p align="center">
<img src="../design/immich-logo-stacked-light.svg" width="300" title="Immich">
</p>
<h3 align="center">ഫോട്ടോകളും വീഡിയോകളും കൈകാര്യം ചെയ്യുന്നതിനുള്ള ഉയർന്ന കാര്യക്ഷമതയുള്ള സെൽഫ്-ഹോസ്റ്റഡ് ആപ്ലിക്കേഷൻ</h3>
<br/>
<a href="https://immich.app">
<img src="../design/immich-screenshots.png" title="പ്രധാന സ്ക്രീൻഷോട്ട്">
</a>
<br/>
<p align="center">
<a href="../README.md">English</a>
<a href="README_ca_ES.md">Català</a>
<a href="README_es_ES.md">Español</a>
<a href="README_fr_FR.md">Français</a>
<a href="README_it_IT.md">Italiano</a>
<a href="README_ja_JP.md">日本語</a>
<a href="README_ko_KR.md">한국어</a>
<a href="README_de_DE.md">Deutsch</a>
<a href="README_nl_NL.md">Nederlands</a>
<a href="README_tr_TR.md">Türkçe</a>
<a href="README_zh_CN.md">简体中文</a>
<a href="README_zh_TW.md">正體中文</a>
<a href="README_uk_UA.md">Українська</a>
<a href="README_ru_RU.md">Русский</a>
<a href="README_pt_BR.md">Português Brasileiro</a>
<a href="README_sv_SE.md">Svenska</a>
<a href="README_ar_JO.md">العربية</a>
<a href="README_vi_VN.md">Tiếng Việt</a>
<a href="README_th_TH.md">ภาษาไทย</a>
</p>
> [!WARNING]
> ⚠️ നിങ്ങളുടെ വിലയേറിയ ഫോട്ടോകൾക്കും വീഡിയോകൾക്കും എല്ലായ്പ്പോഴും [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) ബാക്കപ്പ് പ്ലാൻ പിന്തുടരുക!
>
> [!NOTE]
> ഇൻസ്റ്റാളേഷൻ ഗൈഡുകൾ ഉൾപ്പെടെയുള്ള പ്രധാന ഡോക്യുമെന്റേഷൻ https://immich.app/ എന്ന വെബ്സൈറ്റിൽ നിങ്ങൾക്ക് കണ്ടെത്താം.
## ലിങ്കുകൾ
- [ഡോക്യുമെന്റേഷൻ](https://docs.immich.app/)
- [വിവരങ്ങൾ](https://docs.immich.app/overview/introduction)
- [ഇൻസ്റ്റാളേഷൻ](https://docs.immich.app/install/requirements)
- [റോഡ്മാപ്പ്](https://immich.app/roadmap)
- [ഡെമോ](#ഡെമോ)
- [സവിശേഷതകൾ](#സവിശേഷതകൾ)
- [വിവർത്തനങ്ങൾ](https://docs.immich.app/developer/translations)
- [സംഭാവന നൽകൽ](https://docs.immich.app/overview/support-the-project)
## ഡെമോ
ഡെമോ വെബ്സൈറ്റ് [ഇവിടെ](https://demo.immich.app) ആക്സസ് ചെയ്യാം. മൊബൈൽ ആപ്പിന്റെ ഡെമോ കാണാൻ ആപ്പ് ഇൻസ്റ്റാൾ ചെയ്ത ശേഷം, `Server Endpoint URL` എന്ന സെറ്റിങ്ങിൽ നിങ്ങൾക്ക് `https://demo.immich.app` ഉപയോഗിക്കാം.
### ലോഗിൻ വിവരങ്ങൾ
| ഇമെയിൽ | പാസ്‌വേഡ് |
| --------------- | -------- |
| demo@immich.app | demo |
## സവിശേഷതകൾ
| സവിശേഷതകൾ | മൊബൈൽ | വെബ് |
| :-------------------------------------------------- | ------ | --- |
| ഫോട്ടോകളും വീഡിയോകളും അപ്‌ലോഡ് ചെയ്യാനും കാണാനും സാധിക്കും | ഉണ്ട് | ഉണ്ട് |
| ആപ്പ് തുറക്കുമ്പോൾ ഓട്ടോമാറ്റിക് ബാക്കപ്പ് | ഉണ്ട് | N/A |
| ഫയലുകളുടെ ഇരട്ടിപ്പ് (duplication) തടയുന്നു | ഉണ്ട് | ഉണ്ട് |
| ബാക്കപ്പിനായി നിർദ്ദിഷ്ട ആൽബങ്ങൾ തിരഞ്ഞെടുക്കാം | ഉണ്ട് | N/A |
| ഫോട്ടോകളും വീഡിയോകളും ലോക്കൽ ഡിവൈസിലേക്ക് ഡൗൺലോഡ് ചെയ്യാം | ഉണ്ട് | ഉണ്ട് |
| ഒന്നിലധികം ഉപയോക്താക്കൾക്കുള്ള പിന്തുണ (Multi-user) | ഉണ്ട് | ഉണ്ട് |
| ആൽബങ്ങളും പങ്കിട്ട ആൽബങ്ങളും | ഉണ്ട് | ഉണ്ട് |
| സ്ക്രബ്ബ് ചെയ്യാവുന്ന/വലിച്ചിഴക്കാവുന്ന സ്ക്രോൾബാർ | ഉണ്ട് | ഉണ്ട് |
| റോ (RAW) ഫോർമാറ്റുകൾക്കുള്ള പിന്തുണ | ഉണ്ട് | ഉണ്ട് |
| മെറ്റാഡാറ്റ കാഴ്‌ച (EXIF, മാപ്പ്) | ഉണ്ട് | ഉണ്ട് |
| മെറ്റാഡാറ്റ, ഒബ്‌ജക്റ്റുകൾ, മുഖങ്ങൾ, CLIP എന്നിവ ഉപയോഗിച്ച് തിരയാം | ഉണ്ട് | ഉണ്ട് |
| അഡ്മിനിസ്ട്രേറ്റീവ് പ്രവർത്തനങ്ങൾ (യൂസർ മാനേജ്‌മെന്റ്) | ഇല്ല | ഉണ്ട് |
| ബാക്ക്ഗ്രൗണ്ട് ബാക്കപ്പ് | ഉണ്ട് | N/A |
| വിർച്വൽ സ്ക്രോൾ | ഉണ്ട് | ഉണ്ട് |
| OAuth സപ്പോർട്ട് | ഉണ്ട് | ഉണ്ട് |
| API കീകൾ | N/A | ഉണ്ട് |
| ലൈവ് ഫോട്ടോ/മോഷൻ ഫോട്ടോ ബാക്കപ്പും പ്ലേബാക്കും | ഉണ്ട് | ഉണ്ട് |
| 360 ഡിഗ്രി ഇമേജ് ഡിസ്‌പ്ലേ പിന്തുണ | ഇല്ല | ഉണ്ട് |
| ഉപയോക്താവ് നിർവചിക്കുന്ന സ്റ്റോറേജ് ഘടന | ഉണ്ട് | ഉണ്ട് |
| പബ്ലിക് ഷെയറിംഗ് | ഉണ്ട് | ഉണ്ട് |
| ആർക്കൈവും പ്രിയപ്പെട്ടവയും (Favorites) | ഉണ്ട് | ഉണ്ട് |
| ആഗോള മാപ്പ് (Global Map) | ഉണ്ട് | ഉണ്ട് |
| പങ്കാളി പങ്കിടൽ (Partner Sharing) | ഉണ്ട് | ഉണ്ട് |
| മുഖം തിരിച്ചറിയലും ക്ലസ്റ്ററിംഗും | ഉണ്ട് | ഉണ്ട് |
| ഓർമ്മകൾ (Memories - x വർഷങ്ങൾക്ക് മുമ്പ്) | ഉണ്ട് | ഉണ്ട് |
| ഓഫ്‌ലൈൻ പിന്തുണ | ഉണ്ട് | ഇല്ല |
| റീഡ്-ഒൺലി ഗാലറി | ഉണ്ട് | ഉണ്ട് |
| അടുക്കിവെച്ച ഫോട്ടോകൾ (Stacked Photos) | ഉണ്ട് | ഉണ്ട് |
| ടാഗുകൾ | ഇല്ല | ഉണ്ട് |
| ഫോൾഡർ കാഴ്‌ച | ഉണ്ട് | ഉണ്ട് |
## വിവർത്തനങ്ങൾ
വിവർത്തനങ്ങളെക്കുറിച്ച് കൂടുതൽ [ഇവിടെ](https://docs.immich.app/developer/translations) വായിക്കാം.
<a href="https://hosted.weblate.org/engage/immich/">
<img src="https://hosted.weblate.org/widget/immich/immich/multi-auto.svg" alt="Translation status" />
</a>
## റെപ്പോസിറ്ററി പ്രവർത്തനം
![Activities](https://repobeats.axiom.co/api/embed/9e86d9dc3ddd137161f2f6d2e758d7863b1789cb.svg "Repobeats analytics image")
## സ്റ്റാർ ചരിത്രം
<a href="https://star-history.com/#immich-app/immich&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=immich-app/immich&type=date" width="100%" />
</picture>
</a>
## സംഭാവന ചെയ്തവർ
<a href="https://github.com/immich-app/immich/graphs/contributors">
<img src="https://contrib.rocks/image?repo=immich-app/immich" width="100%"/>
</a>
+1
View File
@@ -35,6 +35,7 @@
<a href="README_sv_SE.md">Svenska</a>
<a href="README_ar_JO.md">العربية</a>
<a href="README_th_TH.md">ภาษาไทย</a>
<a href="README_ml_IN.md">മലയാളം</a>
</p>
> [!WARNING]
+1
View File
@@ -37,6 +37,7 @@
<a href="README_ar_JO.md">العربية</a>
<a href="README_vi_VN.md">Tiếng Việt</a>
<a href="README_th_TH.md">ภาษาไทย</a>
<a href="README_ml_IN.md">മലയാളം</a>
</p>
+1
View File
@@ -37,6 +37,7 @@
<a href="README_ar_JO.md">العربية</a>
<a href="README_vi_VN.md">Tiếng Việt</a>
<a href="README_th_TH.md">ภาษาไทย</a>
<a href="README_ml_IN.md">മലയാളം</a>
</p>
> [!WARNING]
+1
View File
@@ -36,6 +36,7 @@
<a href="README_pt_BR.md">Português Brasileiro</a>
<a href="README_ar_JO.md">العربية</a>
<a href="README_th_TH.md">ภาษาไทย</a>
<a href="README_ml_IN.md">മലയാളം</a>
</p>
> [!WARNING]
+1
View File
@@ -39,6 +39,7 @@
<a href="README_sv_SE.md">Svenska</a>
<a href="README_ar_JO.md">العربية</a>
<a href="README_vi_VN.md">Tiếng Việt</a>
<a href="README_ml_IN.md">മലയാളം</a>
</p>
+1
View File
@@ -35,6 +35,7 @@
<a href="README_sv_SE.md">Svenska</a>
<a href="README_ar_JO.md">العربية</a>
<a href="README_th_TH.md">ภาษาไทย</a>
<a href="README_ml_IN.md">മലയാളം</a>
</p>
> [!WARNING]
+1
View File
@@ -37,6 +37,7 @@
<a href="README_ar_JO.md">العربية</a>
<a href="README_vi_VN.md">Tiếng Việt</a>
<a href="README_th_TH.md">ภาษาไทย</a>
<a href="README_ml_IN.md">മലയാളം</a>
</p>
> [!WARNING]
+1
View File
@@ -38,6 +38,7 @@
<a href="README_ar_JO.md">العربية</a>
<a href="README_vi_VN.md">Tiếng Việt</a>
<a href="README_th_TH.md">ภาษาไทย</a>
<a href="README_ml_IN.md">മലയാളം</a>
</p>
+1
View File
@@ -40,6 +40,7 @@
<a href="README_ar_JO.md">العربية</a>
<a href="README_vi_VN.md">Tiếng Việt</a>
<a href="README_th_TH.md">ภาษาไทย</a>
<a href="README_ml_IN.md">മലയാളം</a>
</p>
+1
View File
@@ -38,6 +38,7 @@
<a href="README_ar_JO.md">العربية</a>
<a href="README_vi_VN.md">Tiếng Việt</a>
<a href="README_th_TH.md">ภาษาไทย</a>
<a href="README_ml_IN.md">മലയാളം</a>
</p>
> [!WARNING]
+2 -2
View File
@@ -1,4 +1,4 @@
FROM ghcr.io/immich-app/base-server-dev:202606161235@sha256:9f88b07acc8b7bf37a1dd3d5a19193f664443eaaab4e08e9f9341414c5e4b23f AS builder
FROM ghcr.io/immich-app/base-server-dev:202607061334@sha256:c83ef9c6d7f5e7f6a276fe96647484d46b7bf6cacc1e35b823dd1d7a1aeda3b8 AS builder
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
CI=1 \
COREPACK_HOME=/tmp \
@@ -80,7 +80,7 @@ RUN --mount=type=cache,id=pnpm-packages,target=/buildcache/pnpm-store \
--mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
mise //:plugins
FROM ghcr.io/immich-app/base-server-prod:202606161235@sha256:c6d59e3923f548d29a212b4dc51b6281a722cfa1da7972a009c0f3830f5762d6
FROM ghcr.io/immich-app/base-server-prod:202607061334@sha256:c07ef6510ff2ccb6ead28434ade7c7a0c08aef33d7d3466b14beccdd431e86f7
WORKDIR /usr/src/app
ENV NODE_ENV=production \
+1 -1
View File
@@ -1,5 +1,5 @@
# dev build
FROM ghcr.io/immich-app/base-server-dev:202606161235@sha256:9f88b07acc8b7bf37a1dd3d5a19193f664443eaaab4e08e9f9341414c5e4b23f AS dev
FROM ghcr.io/immich-app/base-server-dev:202607061334@sha256:c83ef9c6d7f5e7f6a276fe96647484d46b7bf6cacc1e35b823dd1d7a1aeda3b8 AS dev
COPY --from=ghcr.io/jdx/mise:2026.6.14@sha256:b8f8c20fc3308f8b1d00ccca2bc968e4e208af1c5c1069e1ad9753baa099acff /usr/local/bin/mise /usr/local/bin/mise
+5
View File
@@ -4,6 +4,7 @@ import {
AudioCodec,
Colorspace,
CQMode,
HlsVideoResolution,
ImageFormat,
LogLevel,
OAuthTokenEndpointAuthMethod,
@@ -48,6 +49,8 @@ export type SystemConfig = {
tonemap: ToneMapping;
realtime: {
enabled: boolean;
videoCodecs: VideoCodec[];
resolutions: HlsVideoResolution[];
};
};
integrityChecks: {
@@ -247,6 +250,8 @@ export const defaults = Object.freeze<SystemConfig>({
accelDecode: true,
realtime: {
enabled: false,
videoCodecs: [VideoCodec.H264, VideoCodec.Hevc],
resolutions: [HlsVideoResolution.p480, HlsVideoResolution.p720, HlsVideoResolution.p1080],
},
},
integrityChecks: {
+60 -9
View File
@@ -235,14 +235,65 @@ export const HLS_PLAYLIST_CONTENT_TYPE = 'application/vnd.apple.mpegurl';
export const HLS_SEGMENT_DURATION = 2;
export const HLS_SEGMENT_FILENAME_REGEX = /^seg_(\d+)\.m4s$/;
export const HLS_VARIANTS = [
{ resolution: 480, codec: VideoCodec.Av1, bitrate: 1_000_000, codecString: 'av01.0.04M.08' },
{ resolution: 480, codec: VideoCodec.Hevc, bitrate: 1_200_000, codecString: 'hvc1.1.6.L90.B0' },
{ resolution: 480, codec: VideoCodec.H264, bitrate: 2_500_000, codecString: 'avc1.64001e' },
{ resolution: 720, codec: VideoCodec.Av1, bitrate: 2_000_000, codecString: 'av01.0.08M.08' },
{ resolution: 720, codec: VideoCodec.Hevc, bitrate: 2_500_000, codecString: 'hvc1.1.6.L93.B0' },
{ resolution: 720, codec: VideoCodec.H264, bitrate: 5_000_000, codecString: 'avc1.64001f' },
{ resolution: 1080, codec: VideoCodec.Av1, bitrate: 4_000_000, codecString: 'av01.0.09M.08' },
{ resolution: 1080, codec: VideoCodec.Hevc, bitrate: 4_500_000, codecString: 'hvc1.1.6.L120.B0' },
{ resolution: 1080, codec: VideoCodec.H264, bitrate: 8_000_000, codecString: 'avc1.640028' },
{ resolution: 480, codec: VideoCodec.Av1, bitrate: 1_000_000 },
{ resolution: 480, codec: VideoCodec.Hevc, bitrate: 1_200_000 },
{ resolution: 480, codec: VideoCodec.H264, bitrate: 2_500_000 },
{ resolution: 720, codec: VideoCodec.Av1, bitrate: 2_000_000 },
{ resolution: 720, codec: VideoCodec.Hevc, bitrate: 2_500_000 },
{ resolution: 720, codec: VideoCodec.H264, bitrate: 5_000_000 },
{ resolution: 1080, codec: VideoCodec.Av1, bitrate: 4_000_000 },
{ resolution: 1080, codec: VideoCodec.Hevc, bitrate: 4_500_000 },
{ resolution: 1080, codec: VideoCodec.H264, bitrate: 8_000_000 },
{ resolution: 1440, codec: VideoCodec.Av1, bitrate: 7_000_000 },
{ resolution: 1440, codec: VideoCodec.Hevc, bitrate: 8_000_000 },
{ resolution: 1440, codec: VideoCodec.H264, bitrate: 14_000_000 },
{ resolution: 2160, codec: VideoCodec.Av1, bitrate: 12_000_000 },
{ resolution: 2160, codec: VideoCodec.Hevc, bitrate: 14_000_000 },
{ resolution: 2160, codec: VideoCodec.H264, bitrate: 25_000_000 },
];
export const HLS_VERSION = 7;
export type CodecLevel = { maxFrame: number; maxRate: number; token: string };
// H.264 High profile: token is the hex level_idc.
export const H264_LEVELS: CodecLevel[] = [
{ maxFrame: 1620, maxRate: 40_500, token: '1e' }, // 3.0
{ maxFrame: 3600, maxRate: 108_000, token: '1f' }, // 3.1
{ maxFrame: 5120, maxRate: 216_000, token: '20' }, // 3.2
{ maxFrame: 8192, maxRate: 245_760, token: '28' }, // 4.0
{ maxFrame: 8704, maxRate: 522_240, token: '2a' }, // 4.2
{ maxFrame: 22_080, maxRate: 589_824, token: '32' }, // 5.0
{ maxFrame: 36_864, maxRate: 983_040, token: '33' }, // 5.1
{ maxFrame: 36_864, maxRate: 2_073_600, token: '34' }, // 5.2
{ maxFrame: 139_264, maxRate: 4_177_920, token: '3c' }, // 6.0
{ maxFrame: 139_264, maxRate: 8_355_840, token: '3d' }, // 6.1
{ maxFrame: 139_264, maxRate: 16_711_680, token: '3e' }, // 6.2
];
// HEVC Main profile, Main tier: token is `L` + level_idc (level × 30).
export const HEVC_LEVELS: CodecLevel[] = [
{ maxFrame: 552_960, maxRate: 16_588_800, token: 'L90' }, // 3.0
{ maxFrame: 983_040, maxRate: 33_177_600, token: 'L93' }, // 3.1
{ maxFrame: 2_228_224, maxRate: 66_846_720, token: 'L120' }, // 4.0
{ maxFrame: 2_228_224, maxRate: 133_693_440, token: 'L123' }, // 4.1
{ maxFrame: 8_912_896, maxRate: 267_386_880, token: 'L150' }, // 5.0
{ maxFrame: 8_912_896, maxRate: 534_773_760, token: 'L153' }, // 5.1
{ maxFrame: 8_912_896, maxRate: 1_069_547_520, token: 'L156' }, // 5.2
{ maxFrame: 35_651_584, maxRate: 1_069_547_520, token: 'L180' }, // 6.0
{ maxFrame: 35_651_584, maxRate: 2_139_095_040, token: 'L183' }, // 6.1
{ maxFrame: 35_651_584, maxRate: 4_278_190_080, token: 'L186' }, // 6.2
];
// AV1 Main profile (0), Main tier (M): token is the two-digit seq_level_idx + `M`.
export const AV1_LEVELS: CodecLevel[] = [
{ maxFrame: 665_856, maxRate: 19_975_168, token: '04M' }, // 3.0
{ maxFrame: 1_065_024, maxRate: 31_950_336, token: '05M' }, // 3.1
{ maxFrame: 2_359_296, maxRate: 70_778_880, token: '08M' }, // 4.0
{ maxFrame: 2_359_296, maxRate: 141_557_760, token: '09M' }, // 4.1
{ maxFrame: 8_912_896, maxRate: 267_386_880, token: '12M' }, // 5.0
{ maxFrame: 8_912_896, maxRate: 534_773_760, token: '13M' }, // 5.1
{ maxFrame: 8_912_896, maxRate: 1_069_547_520, token: '14M' }, // 5.2
{ maxFrame: 35_651_584, maxRate: 1_069_547_520, token: '16M' }, // 6.0
{ maxFrame: 35_651_584, maxRate: 2_139_095_040, token: '17M' }, // 6.1
{ maxFrame: 35_651_584, maxRate: 4_278_190_080, token: '18M' }, // 6.2
];
+9 -1
View File
@@ -11,6 +11,7 @@ import {
AudioCodecSchema,
ColorspaceSchema,
CQModeSchema,
HlsVideoResolutionSchema,
ImageFormatSchema,
LogLevelSchema,
OAuthTokenEndpointAuthMethodSchema,
@@ -73,7 +74,12 @@ const SystemConfigIntegrityJobSchema = z
const SystemConfigIntegrityChecksumJobSchema = SystemConfigIntegrityJobSchema.extend({
timeLimit: z.int().nonnegative().describe('How long the integrity checksum job may run for'),
percentageLimit: z.int().nonnegative().describe('Percentage limit of the integrity checksum job'),
percentageLimit: z
.float32()
.nonnegative()
.max(1)
.describe('Percentage limit of the integrity checksum job')
.meta({ format: 'double' }),
})
.describe('Integrity checksum job config')
.meta({ id: 'SystemConfigIntegrityChecksumJob' });
@@ -115,6 +121,8 @@ const SystemConfigFFmpegSchema = z
realtime: z
.object({
enabled: configBool.describe('Enable real-time HLS transcoding (alpha)'),
videoCodecs: z.array(VideoCodecSchema).describe('Video codecs to use for real-time HLS transcoding'),
resolutions: z.array(HlsVideoResolutionSchema).describe('Resolutions to use for real-time HLS transcoding'),
})
.meta({ id: 'SystemConfigFFmpegRealtimeDto' }),
})
+13
View File
@@ -529,6 +529,19 @@ export enum CQMode {
export const CQModeSchema = z.enum(CQMode).describe('CQ mode').meta({ id: 'CQMode' });
export enum HlsVideoResolution {
p480 = 480,
p720 = 720,
p1080 = 1080,
p1440 = 1440,
p2160 = 2160,
}
export const HlsVideoResolutionSchema = z
.enum(HlsVideoResolution)
.describe('HLS video resolution')
.meta({ id: 'HlsVideoResolution', type: 'integer' });
export enum Colorspace {
Srgb = 'srgb',
P3 = 'p3',
+2 -3
View File
@@ -130,10 +130,9 @@ from
where
"asset"."deletedAt" is null
and "asset"."isExternal" = false
and "integrity_report"."createdAt" >= $2
and "integrity_report"."createdAt" <= $3
and "asset"."createdAt" >= $2
order by
"integrity_report"."createdAt" asc
"asset"."createdAt" asc
-- IntegrityRepository.streamIntegrityReports
select
@@ -160,8 +160,8 @@ export class IntegrityRepository {
>;
}
@GenerateSql({ params: [DummyValue.DATE, DummyValue.DATE], stream: true })
streamAssetChecksums(startMarker?: Date, endMarker?: Date) {
@GenerateSql({ params: [DummyValue.DATE], stream: true })
streamAssetChecksums(startMarker?: Date) {
return this.db
.selectFrom('asset')
.where('asset.deletedAt', 'is', null)
@@ -178,9 +178,8 @@ export class IntegrityRepository {
'integrity_report.id as reportId',
])
.where('asset.isExternal', '=', sql.lit(false))
.$if(startMarker !== undefined, (qb) => qb.where('integrity_report.createdAt', '>=', startMarker!))
.$if(endMarker !== undefined, (qb) => qb.where('integrity_report.createdAt', '<=', endMarker!))
.orderBy('integrity_report.createdAt', 'asc')
.$if(startMarker !== undefined, (qb) => qb.where('asset.createdAt', '>=', startMarker!))
.orderBy('asset.createdAt', 'asc')
.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.');
}
if (auth.session) {
if (auth.session && sid) {
await this.sessionRepository.update(auth.session.id, { oauthSid: sid });
}
+67 -1
View File
@@ -407,7 +407,7 @@ export class DatabaseBackupService {
progressCb?.('restore', progress);
});
await pipeline(sqlStream, progressSource, psql, progressSink);
await pipeline(sqlStream, createSqlOwnerTransformStream(databaseUsername), progressSource, psql, progressSink);
try {
progressCb?.('migrations', 0.9);
@@ -572,3 +572,69 @@ function createSqlProgressStreams(cb: (progress: number) => void) {
return [source, sink];
}
function createSqlOwnerTransformStream(databaseUsername: string) {
const OWNER_MARKER_START = new TextEncoder().encode('OWNER TO ');
const DATA_MARKER_START = new TextEncoder().encode('FROM stdin');
const LINE_END = new TextEncoder().encode(';');
const owner = new TextEncoder().encode(databaseUsername);
let ownerSequenceIndex = 0;
let replacingOwnerIndex = 0;
let replacingOwner = false;
let readingDataIndex = 0;
let dataPart = false;
return new PassThrough({
transform(chunk, _encoding, callback) {
let result = chunk;
if (!dataPart) {
for (let index = 0; index < result.length; index++) {
if (replacingOwner) {
if (result[index] === LINE_END[0]) {
result = Buffer.concat([result.slice(0, index), owner.slice(replacingOwnerIndex), result.slice(index)]);
replacingOwnerIndex = owner.length;
} else {
result[index] = owner[replacingOwnerIndex];
replacingOwnerIndex++;
}
}
if (replacingOwnerIndex === owner.length) {
replacingOwner = false;
}
if (result[index] === OWNER_MARKER_START[ownerSequenceIndex]) {
ownerSequenceIndex++;
} else {
ownerSequenceIndex = 0;
}
if (ownerSequenceIndex === OWNER_MARKER_START.length) {
ownerSequenceIndex = 0;
replacingOwner = true;
replacingOwnerIndex = 0;
}
if (result[index] === DATA_MARKER_START[readingDataIndex]) {
readingDataIndex++;
} else {
readingDataIndex = 0;
}
if (readingDataIndex === DATA_MARKER_START.length) {
dataPart = true;
break;
}
}
}
this.push(result);
callback();
},
});
}
+66 -39
View File
@@ -1,5 +1,5 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { TranscodeHardwareAcceleration } from 'src/enum';
import { HlsVideoResolution, VideoCodec } from 'src/enum';
import { HlsService } from 'src/services/hls.service';
import { eiffelTower, train, waterfall } from 'test/fixtures/media.stub';
import { factory } from 'test/small.factory';
@@ -96,67 +96,79 @@ seg_10.m4s
const sessionId = '00000000-0000-0000-0000-000000000000';
const eiffelExpectedMasterDisabled = `#EXTM3U
const eiffelExpectedMasterAv1 = `#EXTM3U
#EXT-X-VERSION:7
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-STREAM-INF:BANDWIDTH=1000000,RESOLUTION=480x852,CODECS="av01.0.04M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
#EXT-X-STREAM-INF:BANDWIDTH=1350000,RESOLUTION=480x852,CODECS="av01.0.04M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
${sessionId}/0/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=1200000,RESOLUTION=480x852,CODECS="hvc1.1.6.L90.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
#EXT-X-STREAM-INF:BANDWIDTH=1620000,RESOLUTION=480x852,CODECS="hvc1.1.6.L90.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
${sessionId}/1/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=480x852,CODECS="avc1.64001e,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
#EXT-X-STREAM-INF:BANDWIDTH=3375000,RESOLUTION=480x852,CODECS="avc1.64001e,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
${sessionId}/2/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=2000000,RESOLUTION=720x1280,CODECS="av01.0.08M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
#EXT-X-STREAM-INF:BANDWIDTH=2700000,RESOLUTION=720x1280,CODECS="av01.0.05M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
${sessionId}/3/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=720x1280,CODECS="hvc1.1.6.L93.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
#EXT-X-STREAM-INF:BANDWIDTH=3375000,RESOLUTION=720x1280,CODECS="hvc1.1.6.L93.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
${sessionId}/4/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=5000000,RESOLUTION=720x1280,CODECS="avc1.64001f,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
#EXT-X-STREAM-INF:BANDWIDTH=6750000,RESOLUTION=720x1280,CODECS="avc1.64001f,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
${sessionId}/5/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=4000000,RESOLUTION=1080x1920,CODECS="av01.0.09M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
#EXT-X-STREAM-INF:BANDWIDTH=5400000,RESOLUTION=1080x1920,CODECS="av01.0.08M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
${sessionId}/6/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=4500000,RESOLUTION=1080x1920,CODECS="hvc1.1.6.L120.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
#EXT-X-STREAM-INF:BANDWIDTH=6075000,RESOLUTION=1080x1920,CODECS="hvc1.1.6.L120.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
${sessionId}/7/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=8000000,RESOLUTION=1080x1920,CODECS="avc1.640028,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
#EXT-X-STREAM-INF:BANDWIDTH=10800000,RESOLUTION=1080x1920,CODECS="avc1.640028,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
${sessionId}/8/playlist.m3u8
`;
const eiffelExpectedMasterRkmpp = `#EXTM3U
const eiffelExpectedMasterNoAv1 = `#EXTM3U
#EXT-X-VERSION:7
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-STREAM-INF:BANDWIDTH=1200000,RESOLUTION=480x852,CODECS="hvc1.1.6.L90.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
#EXT-X-STREAM-INF:BANDWIDTH=1620000,RESOLUTION=480x852,CODECS="hvc1.1.6.L90.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
${sessionId}/1/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=480x852,CODECS="avc1.64001e,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
#EXT-X-STREAM-INF:BANDWIDTH=3375000,RESOLUTION=480x852,CODECS="avc1.64001e,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
${sessionId}/2/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=720x1280,CODECS="hvc1.1.6.L93.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
#EXT-X-STREAM-INF:BANDWIDTH=3375000,RESOLUTION=720x1280,CODECS="hvc1.1.6.L93.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
${sessionId}/4/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=5000000,RESOLUTION=720x1280,CODECS="avc1.64001f,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
#EXT-X-STREAM-INF:BANDWIDTH=6750000,RESOLUTION=720x1280,CODECS="avc1.64001f,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
${sessionId}/5/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=4500000,RESOLUTION=1080x1920,CODECS="hvc1.1.6.L120.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
#EXT-X-STREAM-INF:BANDWIDTH=6075000,RESOLUTION=1080x1920,CODECS="hvc1.1.6.L120.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
${sessionId}/7/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=8000000,RESOLUTION=1080x1920,CODECS="avc1.640028,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
#EXT-X-STREAM-INF:BANDWIDTH=10800000,RESOLUTION=1080x1920,CODECS="avc1.640028,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
${sessionId}/8/playlist.m3u8
`;
const waterfallExpectedMasterDisabled = `#EXTM3U
const waterfallExpectedMasterAv1 = `#EXTM3U
#EXT-X-VERSION:7
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-STREAM-INF:BANDWIDTH=1000000,RESOLUTION=480x852,CODECS="av01.0.04M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
#EXT-X-STREAM-INF:BANDWIDTH=1350000,RESOLUTION=480x852,CODECS="av01.0.04M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
${sessionId}/0/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=1200000,RESOLUTION=480x852,CODECS="hvc1.1.6.L90.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
#EXT-X-STREAM-INF:BANDWIDTH=1620000,RESOLUTION=480x852,CODECS="hvc1.1.6.L90.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
${sessionId}/1/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=480x852,CODECS="avc1.64001e,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
#EXT-X-STREAM-INF:BANDWIDTH=3375000,RESOLUTION=480x852,CODECS="avc1.64001f,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
${sessionId}/2/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=2000000,RESOLUTION=720x1280,CODECS="av01.0.08M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
#EXT-X-STREAM-INF:BANDWIDTH=2700000,RESOLUTION=720x1280,CODECS="av01.0.05M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
${sessionId}/3/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=720x1280,CODECS="hvc1.1.6.L93.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
#EXT-X-STREAM-INF:BANDWIDTH=3375000,RESOLUTION=720x1280,CODECS="hvc1.1.6.L93.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
${sessionId}/4/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=5000000,RESOLUTION=720x1280,CODECS="avc1.64001f,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
#EXT-X-STREAM-INF:BANDWIDTH=6750000,RESOLUTION=720x1280,CODECS="avc1.64001f,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
${sessionId}/5/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=4000000,RESOLUTION=1080x1920,CODECS="av01.0.09M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
#EXT-X-STREAM-INF:BANDWIDTH=5400000,RESOLUTION=1080x1920,CODECS="av01.0.08M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
${sessionId}/6/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=4500000,RESOLUTION=1080x1920,CODECS="hvc1.1.6.L120.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
#EXT-X-STREAM-INF:BANDWIDTH=6075000,RESOLUTION=1080x1920,CODECS="hvc1.1.6.L120.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
${sessionId}/7/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=8000000,RESOLUTION=1080x1920,CODECS="avc1.640028,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
#EXT-X-STREAM-INF:BANDWIDTH=10800000,RESOLUTION=1080x1920,CODECS="avc1.640028,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
${sessionId}/8/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=9450000,RESOLUTION=1440x2560,CODECS="av01.0.12M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
${sessionId}/9/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=10800000,RESOLUTION=1440x2560,CODECS="hvc1.1.6.L150.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
${sessionId}/10/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=18900000,RESOLUTION=1440x2560,CODECS="avc1.640032,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
${sessionId}/11/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=16200000,RESOLUTION=2160x3840,CODECS="av01.0.12M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
${sessionId}/12/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=18900000,RESOLUTION=2160x3840,CODECS="hvc1.1.6.L150.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
${sessionId}/13/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=33750000,RESOLUTION=2160x3840,CODECS="avc1.640033,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
${sessionId}/14/playlist.m3u8
`;
describe(HlsService.name, () => {
@@ -171,9 +183,24 @@ describe(HlsService.name, () => {
const auth = factory.auth();
const assetId = 'asset-1';
const setup = (asset: typeof eiffelTower | typeof waterfall, accel: TranscodeHardwareAcceleration) => {
const allCodecs = [VideoCodec.Av1, VideoCodec.Hevc, VideoCodec.H264];
const allResolutions = [
HlsVideoResolution.p480,
HlsVideoResolution.p720,
HlsVideoResolution.p1080,
HlsVideoResolution.p1440,
HlsVideoResolution.p2160,
];
const setup = (
asset: typeof eiffelTower | typeof waterfall,
videoCodecs?: VideoCodec[],
resolutions?: HlsVideoResolution[],
) => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { realtime: { enabled: true }, accel } });
mocks.systemMetadata.get.mockResolvedValue({
ffmpeg: { realtime: { enabled: true, videoCodecs, resolutions } },
});
mocks.videoStream.getForMainPlaylist.mockResolvedValue(asset);
mocks.crypto.randomUUID.mockReturnValue(sessionId);
mocks.websocket.serverSend.mockImplementation((event, ...rest) => {
@@ -184,19 +211,19 @@ describe(HlsService.name, () => {
});
};
it('returns main playlist for eiffel-tower (1080p portrait, no acceleration)', async () => {
setup(eiffelTower, TranscodeHardwareAcceleration.Disabled);
await expect(sut.getMainPlaylist(auth, assetId)).resolves.toBe(eiffelExpectedMasterDisabled);
it('offers AV1, HEVC, and H.264 when AV1 is configured and the accelerator supports it', async () => {
setup(eiffelTower, allCodecs);
await expect(sut.getMainPlaylist(auth, assetId)).resolves.toBe(eiffelExpectedMasterAv1);
});
it('returns main playlist for eiffel-tower with RKMPP (no AV1 variants)', async () => {
setup(eiffelTower, TranscodeHardwareAcceleration.Rkmpp);
await expect(sut.getMainPlaylist(auth, assetId)).resolves.toBe(eiffelExpectedMasterRkmpp);
it('omits AV1 when it is not in the configured codecs', async () => {
setup(eiffelTower);
await expect(sut.getMainPlaylist(auth, assetId)).resolves.toBe(eiffelExpectedMasterNoAv1);
});
it('returns main playlist for waterfall (4K landscape) with no acceleration', async () => {
setup(waterfall, TranscodeHardwareAcceleration.Disabled);
await expect(sut.getMainPlaylist(auth, assetId)).resolves.toBe(waterfallExpectedMasterDisabled);
it('offers every resolution up to the source and derives 4K codec levels (waterfall, 4K, 29.83fps)', async () => {
setup(waterfall, allCodecs, allResolutions);
await expect(sut.getMainPlaylist(auth, assetId)).resolves.toBe(waterfallExpectedMasterAv1);
});
it('throws BadRequestException when realtime transcoding is disabled', async () => {
+9 -12
View File
@@ -1,13 +1,7 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { constants } from 'node:fs';
import { join } from 'node:path';
import {
HLS_SEGMENT_DURATION,
HLS_SEGMENT_FILENAME_REGEX,
HLS_VARIANTS,
HLS_VERSION,
SUPPORTED_HWA_CODECS,
} from 'src/constants';
import { HLS_SEGMENT_DURATION, HLS_SEGMENT_FILENAME_REGEX, HLS_VARIANTS, HLS_VERSION } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core';
import { OnEvent } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
@@ -18,7 +12,7 @@ import { BaseService } from 'src/services/base.service';
import { VideoPacketInfo, VideoStreamInfo } from 'src/types';
import { PendingEvents } from 'src/utils/event';
import { ImmichFileResponse } from 'src/utils/file';
import { getOutputSize } from 'src/utils/media';
import { getCodecString, getOutputSize } from 'src/utils/media';
type AssetWithStreamInfo = { videoStream: VideoStreamInfo & { timeBase: number }; packets: VideoPacketInfo };
type Segmentation = { fps: number; framesPerSegment: number; segmentCount: number; segmentDuration: number };
@@ -131,18 +125,21 @@ export class HlsService extends BaseService {
}
private generateMainPlaylist(sessionId: string, ffmpeg: SystemConfigFFmpegDto, asset: AssetWithStreamInfo) {
const fps = ((asset.packets.packetCount * asset.videoStream.timeBase) / asset.packets.totalDuration).toFixed(3);
const fps = (asset.packets.packetCount * asset.videoStream.timeBase) / asset.packets.totalDuration;
const roundedFps = fps.toFixed(3);
const sourceResolution = Math.min(asset.videoStream.height, asset.videoStream.width);
const targetResolution = Math.max(sourceResolution, HLS_VARIANTS[0].resolution);
const lines = ['#EXTM3U', `#EXT-X-VERSION:${HLS_VERSION}`, '#EXT-X-INDEPENDENT-SEGMENTS'];
const { videoCodecs, resolutions } = ffmpeg.realtime;
for (let i = 0; i < HLS_VARIANTS.length; i++) {
const { resolution, bitrate, codec, codecString } = HLS_VARIANTS[i];
if (resolution > targetResolution || !SUPPORTED_HWA_CODECS[ffmpeg.accel].includes(codec)) {
const { resolution, bitrate, codec } = HLS_VARIANTS[i];
if (resolution > targetResolution || !videoCodecs.includes(codec) || !resolutions.includes(resolution)) {
continue;
}
const { width, height } = getOutputSize(asset.videoStream, resolution);
const codecString = getCodecString(codec, width, height, fps);
lines.push(
`#EXT-X-STREAM-INF:BANDWIDTH=${bitrate},RESOLUTION=${width}x${height},CODECS="${codecString},mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=${fps}`,
`#EXT-X-STREAM-INF:BANDWIDTH=${Math.round(bitrate * 1.35)},RESOLUTION=${width}x${height},CODECS="${codecString},mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=${roundedFps}`,
`${sessionId}/${i}/playlist.m3u8`,
);
}
+15 -22
View File
@@ -532,8 +532,7 @@ export class IntegrityService extends BaseService {
const { count } = await this.integrityRepository.getAssetCount();
const checkpoint = await this.systemMetadataRepository.get(SystemMetadataKey.IntegrityChecksumCheckpoint);
let startMarker: Date | undefined = checkpoint?.date ? new Date(checkpoint.date) : undefined;
let endMarker: Date | undefined;
const startMarker = checkpoint?.date ? new Date(checkpoint.date) : undefined;
const printStats = () => {
const averageTime = ((Date.now() - startedAt) / processed).toFixed(2);
@@ -546,31 +545,25 @@ export class IntegrityService extends BaseService {
let lastCreatedAt: Date | undefined;
finishEarly: do {
this.logger.log(
`Processing assets in range [${startMarker?.toISOString() ?? 'beginning'}, ${endMarker?.toISOString() ?? 'end'}]`,
);
this.logger.log(`Processing assets from ${startMarker?.toISOString() ?? 'beginning'}`);
const assets = this.integrityRepository.streamAssetChecksums(startMarker, endMarker);
endMarker = startMarker;
startMarker = undefined;
const assets = this.integrityRepository.streamAssetChecksums(startMarker);
for await (const { originalPath, checksum, createdAt, assetId, reportId } of assets) {
await this.checkAssetChecksum(originalPath, checksum, assetId, reportId);
for await (const { originalPath, checksum, createdAt, assetId, reportId } of assets) {
await this.checkAssetChecksum(originalPath, checksum, assetId, reportId);
processed++;
processed++;
if (processed % 100 === 0) {
printStats();
}
if (Date.now() > startedAt + timeLimit || processed > count * percentageLimit) {
this.logger.log('Reached stop criteria.');
lastCreatedAt = createdAt;
break finishEarly;
}
if (processed % 100 === 0) {
printStats();
}
} 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, {
date: lastCreatedAt?.toISOString(),
@@ -5,6 +5,7 @@ import {
AudioCodec,
Colorspace,
CQMode,
HlsVideoResolution,
ImageFormat,
LogLevel,
OAuthTokenEndpointAuthMethod,
@@ -76,6 +77,8 @@ const updatedConfig = Object.freeze<SystemConfig>({
tonemap: ToneMapping.Hable,
realtime: {
enabled: false,
videoCodecs: [VideoCodec.H264, VideoCodec.Hevc],
resolutions: [HlsVideoResolution.p480, HlsVideoResolution.p720, HlsVideoResolution.p1080],
},
},
integrityChecks: {
+24 -1
View File
@@ -1,4 +1,4 @@
import { AUDIO_ENCODER, SUPPORTED_HWA_CODECS } from 'src/constants';
import { AUDIO_ENCODER, AV1_LEVELS, CodecLevel, H264_LEVELS, HEVC_LEVELS, SUPPORTED_HWA_CODECS } from 'src/constants';
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
import {
ColorMatrix,
@@ -36,6 +36,29 @@ export const getOutputSize = (videoStream: VideoStreamInfo, targetRes: number) =
return isVideoVertical(videoStream) ? { width: targetRes, height: larger } : { width: larger, height: targetRes };
};
const pickLevel = (levels: CodecLevel[], frame: number, rate: number) =>
levels.find((level) => frame <= level.maxFrame && rate <= level.maxRate) ?? levels.at(-1)!;
export const getCodecString = (codec: VideoCodec, width: number, height: number, fps: number): string => {
switch (codec) {
case VideoCodec.H264: {
const macroblocks = Math.ceil(width / 16) * Math.ceil(height / 16);
return `avc1.6400${pickLevel(H264_LEVELS, macroblocks, macroblocks * fps).token}`;
}
case VideoCodec.Hevc: {
const samples = width * height;
return `hvc1.1.6.${pickLevel(HEVC_LEVELS, samples, samples * fps).token}.B0`;
}
case VideoCodec.Av1: {
const samples = width * height;
return `av01.0.${pickLevel(AV1_LEVELS, samples, samples * fps).token}.08`;
}
default: {
throw new Error(`Codec '${codec}' does not support HLS codec strings`);
}
}
};
export class BaseConfig implements VideoCodecSWConfig {
readonly presets = ['veryslow', 'slower', 'slow', 'medium', 'fast', 'faster', 'veryfast', 'superfast', 'ultrafast'];
protected constructor(
@@ -1,9 +1,10 @@
import { Kysely } from 'kysely';
import { DateTime } from 'luxon';
import { createHash, randomUUID } from 'node:crypto';
import { Readable } from 'node:stream';
import { text } from 'node:stream/consumers';
import { StorageCore } from 'src/cores/storage.core';
import { AssetFileType, IntegrityReport, JobName, JobStatus } from 'src/enum';
import { AssetFileType, IntegrityReport, JobName, JobStatus, SystemMetadataKey } from 'src/enum';
import { AssetRepository } from 'src/repositories/asset.repository';
import { ConfigRepository } from 'src/repositories/config.repository';
import { EventRepository } from 'src/repositories/event.repository';
@@ -702,6 +703,30 @@ describe(IntegrityService.name, () => {
ctx.get(IntegrityRepository).getIntegrityReport({ limit: 100 }, IntegrityReport.ChecksumFail),
).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', () => {
+9
View File
@@ -58,5 +58,14 @@ describe('i18n', () => {
expect(getClosestAvailableLocale(['sr_Cyrl'], allLocales)).toBe('sr_Cyrl');
expect(getClosestAvailableLocale(['zh_Hant'], allLocales)).toBe('zh_Hant');
});
it('should handle language aliases', () => {
const allLocales = ['zh-Hans', 'zh-Hant'];
expect(getClosestAvailableLocale(['zh-CN'], allLocales)).toBe('zh-Hans');
expect(getClosestAvailableLocale(['zh-HK'], allLocales)).toBe('zh-Hant');
expect(getClosestAvailableLocale(['zh-MO'], allLocales)).toBe('zh-Hant');
expect(getClosestAvailableLocale(['zh-SG'], allLocales)).toBe('zh-Hans');
expect(getClosestAvailableLocale(['zh-TW'], allLocales)).toBe('zh-Hant');
});
});
});
@@ -15,12 +15,12 @@ import {
fromTimelinePlainDate,
fromTimelinePlainDateTime,
fromTimelinePlainYearMonth,
fromISODateTimeUTCToObject,
getTimes,
setDifference,
type TimelineDateTime,
type TimelineYearMonth,
getOrderingDate,
fromISODateTimeUTC,
} from '$lib/utils/timeline-util';
import { GroupInsertionCache } from './group-insertion-cache.svelte';
import { TimelineDay } from './timeline-day.svelte';
@@ -190,7 +190,7 @@ export class TimelineMonth {
isVideo: !bucketAssets.isImage[i],
livePhotoVideoId: bucketAssets.livePhotoVideoId[i],
localDateTime,
createdAt: fromISODateTimeUTCToObject(bucketAssets.createdAt[i]),
createdAt: fromISODateTimeUTC(bucketAssets.createdAt[i]).toLocal().toObject(),
fileCreatedAt,
ownerId: bucketAssets.ownerId[i],
projectionType: bucketAssets.projectionType[i],
+1 -1
View File
@@ -122,7 +122,7 @@
options={[
{ label: $t('role_editor'), value: AlbumUserRole.Editor },
{ label: $t('role_viewer'), value: AlbumUserRole.Viewer },
{ label: $t('owner'), value: AlbumUserRole.Owner },
{ label: $t('owner'), value: AlbumUserRole.Owner, disabled: true },
{ label: $t('remove_user'), value: 'none' },
] as SelectOption<AlbumUserRole | 'none'>[]}
onChange={(value) => handleRoleSelect(user, value)}
+6 -3
View File
@@ -134,10 +134,13 @@ const notifyAddToAlbum = ($t: MessageFormatter, albumId: string, assetIds: strin
const successCount = results.filter(({ success }) => success).length;
const duplicateCount = results.filter(({ error }) => error === 'duplicate').length;
let description = $t('assets_cannot_be_added_to_album_count', { values: { count: assetIds.length } });
if (successCount > 0) {
description = $t('assets_added_to_album_count', { values: { count: successCount } });
} else if (duplicateCount > 0) {
if (duplicateCount === assetIds.length) {
description = $t('assets_were_part_of_album_count', { values: { count: duplicateCount } });
} else if (successCount === assetIds.length) {
description = $t('assets_added_to_album_count', { values: { count: successCount } });
} else if (successCount > 0) {
description = $t('assets_added_to_album_partial_count', { values: { successCount, totalCount: assetIds.length } });
}
toastManager.primary(
+11 -1
View File
@@ -13,6 +13,14 @@ export const getFormatter = async () => {
return get(t);
};
const aliases: Record<string, string> = {
'zh-CN': 'zh-Hans',
'zh-HK': 'zh-Hant',
'zh-MO': 'zh-Hant',
'zh-SG': 'zh-Hans',
'zh-TW': 'zh-Hant',
};
const modules = import.meta.glob('$i18n/*.json');
const fileCodes = Object.keys(modules)
@@ -33,7 +41,9 @@ const getSubLocales = (locale: string) => {
export const getClosestAvailableLocale = (locales: readonly string[], allLocales: readonly string[]) => {
const allLocalesSet = new Set(allLocales.map((locale) => convertBCP47(locale)));
return locales.find((locale) => getSubLocales(locale).some((subLocale) => allLocalesSet.has(subLocale)));
return locales
.map((locale) => aliases[locale] ?? locale)
.find((locale) => getSubLocales(locale).some((subLocale) => allLocalesSet.has(subLocale)));
};
export const getPreferredLocale = () => getClosestAvailableLocale(navigator.languages, langCodes);
@@ -12,6 +12,7 @@
import {
AudioCodec,
CQMode,
HlsVideoResolution,
ToneMapping,
TranscodeHWAccel,
TranscodePolicy,
@@ -401,9 +402,45 @@
title={$t('admin.transcoding_realtime_enabled')}
subtitle={$t('admin.transcoding_realtime_enabled_description')}
bind:checked={configToEdit.ffmpeg.realtime.enabled}
isEdited={configToEdit.ffmpeg.realtime.enabled !== configToEdit.ffmpeg.realtime.enabled}
isEdited={configToEdit.ffmpeg.realtime.enabled !== config.ffmpeg.realtime.enabled}
{disabled}
/>
<SettingCheckboxes
label={$t('admin.transcoding_realtime_video_codecs')}
desc={$t('admin.transcoding_realtime_video_codecs_description')}
disabled={disabled || !configToEdit.ffmpeg.realtime.enabled}
bind:value={configToEdit.ffmpeg.realtime.videoCodecs}
name="realtimeVideoCodecs"
options={[
{ value: VideoCodec.H264, text: 'H.264' },
{ value: VideoCodec.Hevc, text: 'HEVC' },
{ value: VideoCodec.Av1, text: 'AV1' },
]}
isEdited={!isEqual(
sortBy(configToEdit.ffmpeg.realtime.videoCodecs),
sortBy(config.ffmpeg.realtime.videoCodecs),
)}
/>
<SettingCheckboxes
label={$t('admin.transcoding_realtime_resolutions')}
desc={$t('admin.transcoding_realtime_resolutions_description')}
disabled={disabled || !configToEdit.ffmpeg.realtime.enabled}
bind:value={configToEdit.ffmpeg.realtime.resolutions}
name="realtimeResolutions"
options={[
{ value: HlsVideoResolution.$480, text: '480p' },
{ value: HlsVideoResolution.$720, text: '720p' },
{ value: HlsVideoResolution.$1080, text: '1080p' },
{ value: HlsVideoResolution.$1440, text: '1440p' },
{ value: HlsVideoResolution.$2160, text: '2160p' },
]}
isEdited={!isEqual(
sortBy(configToEdit.ffmpeg.realtime.resolutions),
sortBy(config.ffmpeg.realtime.resolutions),
)}
/>
</div>
</SettingAccordion>
</div>
@@ -1,4 +1,4 @@
<script lang="ts" generics="T extends string">
<script lang="ts" generics="T extends string | number">
import { Checkbox, Label } from '@immich/ui';
import { t } from 'svelte-i18n';
import { quintOut } from 'svelte/easing';