Compare commits

..

2 Commits

Author SHA1 Message Date
mertalev 5fefd13ac8 abort local sync 2026-05-30 20:19:24 -04:00
mertalev f1f8142409 event-based cancellation
wire hash cancellation

await cleanup

remove forced kill

add regression tests

abort sync requests

fix cleanup ordering in teardown

exit isolate

test background sync

test sigabrt crash

cleanup
2026-05-30 20:12:01 -04:00
131 changed files with 1561 additions and 5257 deletions
@@ -15,7 +15,7 @@ services:
volumes:
- ${UPLOAD_LOCATION:-upload-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/data
- /etc/localtime:/etc/localtime:ro
- build_cache:/buildcache
- pnpm_store_server:/buildcache/pnpm-store
- ../packages/plugin-core:/build/plugins/immich-plugin-core
immich-web:
env_file: !reset []
+2 -4
View File
@@ -49,9 +49,7 @@ jobs:
- name: Publish
if: ${{ github.event_name == 'release' }}
env:
NPM_TAG: ${{ github.event.release.prerelease && 'rc' || 'latest' }}
run: mise run ci-publish -- --tag "$NPM_TAG"
run: mise run ci-publish
docker:
name: Docker
@@ -104,7 +102,7 @@ jobs:
name=ghcr.io/${{ github.repository_owner }}/immich-cli
tags: |
type=raw,value=${{ steps.package-version.outputs.version }},enable=${{ github.event_name == 'release' }}
type=raw,value=latest,enable=${{ github.event_name == 'release' && !github.event.release.prerelease }}
type=raw,value=latest,enable=${{ github.event_name == 'release' }}
- name: Build and push image
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
+4 -4
View File
@@ -132,7 +132,7 @@ jobs:
suffixes: '-rocm'
platforms: linux/amd64
runner-mapping: '{"linux/amd64": "pokedex-large"}'
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@db54dcf16fbb12c43479a23749ceea0ad1b4a704 # multi-runner-build-workflow-v3.0.0
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@5813c7c4f7016c748ae7ac5d5f684846649d4d20 # multi-runner-build-workflow-v2.4.0
permissions:
contents: read
actions: read
@@ -147,7 +147,7 @@ jobs:
platforms: ${{ matrix.platforms }}
runner-mapping: ${{ matrix.runner-mapping }}
suffixes: ${{ matrix.suffixes }}
dockerhub-push: ${{ github.event_name == 'release' && !github.event.release.prerelease }}
dockerhub-push: ${{ github.event_name == 'release' }}
build-args: |
DEVICE=${{ matrix.device }}
@@ -155,7 +155,7 @@ jobs:
name: Build and Push Server
needs: pre-job
if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == true }}
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@db54dcf16fbb12c43479a23749ceea0ad1b4a704 # multi-runner-build-workflow-v3.0.0
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@5813c7c4f7016c748ae7ac5d5f684846649d4d20 # multi-runner-build-workflow-v2.4.0
permissions:
contents: read
actions: read
@@ -167,7 +167,7 @@ jobs:
image: immich-server
context: .
dockerfile: server/Dockerfile
dockerhub-push: ${{ github.event_name == 'release' && !github.event.release.prerelease }}
dockerhub-push: ${{ github.event_name == 'release' }}
build-args: |
DEVICE=cpu
+2 -10
View File
@@ -98,16 +98,9 @@ jobs:
shouldDeploy: true
};
} else if (eventType == "release") {
const tag = context.payload.workflow_run.head_branch;
const { data: release } = await github.rest.repos.getReleaseByTag({
owner: context.repo.owner,
repo: context.repo.repo,
tag,
});
parameters = {
event: "release",
name: tag,
prerelease: release.prerelease,
name: context.payload.workflow_run.head_branch,
shouldDeploy: !isFork
};
}
@@ -153,7 +146,6 @@ jobs:
const parameters = JSON.parse(process.env.PARAM_JSON);
core.setOutput("event", parameters.event);
core.setOutput("name", parameters.name);
core.setOutput("prerelease", parameters.prerelease);
core.setOutput("shouldDeploy", parameters.shouldDeploy);
- name: Download artifact
@@ -211,7 +203,7 @@ jobs:
run: mise run //docs:deploy
- name: Deploy Docs Release Domain
if: ${{ steps.parameters.outputs.event == 'release' && steps.parameters.outputs.prerelease != 'true' }}
if: ${{ steps.parameters.outputs.event == 'release' }}
env:
TF_VAR_prefix_name: ${{ steps.parameters.outputs.name}}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+1 -1
View File
@@ -137,7 +137,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@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2
with:
draft: true
tag_name: ${{ needs.bump_version.outputs.version }}
+1 -3
View File
@@ -39,6 +39,4 @@ jobs:
run: pnpm --filter @immich/sdk build
- name: Publish
env:
NPM_TAG: ${{ github.event.release.prerelease && 'rc' || 'latest' }}
run: pnpm --filter @immich/sdk publish --provenance --no-git-checks --tag "$NPM_TAG"
run: pnpm --filter @immich/sdk publish --provenance --no-git-checks
+2 -2
View File
@@ -374,7 +374,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
@@ -451,7 +451,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
@@ -95,7 +95,6 @@ describe('/server', () => {
major: expect.any(Number),
minor: expect.any(Number),
patch: expect.any(Number),
prerelease: null,
});
});
});
@@ -116,7 +115,6 @@ describe('/server', () => {
oauthAutoLaunch: false,
ocr: false,
passwordLogin: true,
realtimeTranscoding: false,
search: true,
sidecar: true,
trash: true,
@@ -21,18 +21,18 @@ describe('/system-config', () => {
const response1 = await request(app)
.put('/system-config')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ ...config, newVersionCheck: { enabled: false, channel: 'stable' } });
.send({ ...config, newVersionCheck: { enabled: false } });
expect(response1.status).toBe(200);
expect(response1.body).toEqual({ ...config, newVersionCheck: { enabled: false, channel: 'stable' } });
expect(response1.body).toEqual({ ...config, newVersionCheck: { enabled: false } });
const response2 = await request(app)
.put('/system-config')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ ...config, newVersionCheck: { enabled: true, channel: 'stable' } });
.send({ ...config, newVersionCheck: { enabled: true } });
expect(response2.status).toBe(200);
expect(response2.body).toEqual({ ...config, newVersionCheck: { enabled: true, channel: 'stable' } });
expect(response2.body).toEqual({ ...config, newVersionCheck: { enabled: true } });
});
it('should reject an invalid config entry', async () => {
-11
View File
@@ -305,8 +305,6 @@
"refreshing_all_libraries": "Refreshing all libraries",
"registration": "Admin Registration",
"registration_description": "Since you are the first user on the system, you will be assigned as the Admin and are responsible for administrative tasks, and additional users will be created by you.",
"release_channel_release_candidate": "Release candidate",
"release_channel_stable": "Stable",
"remove_failed_jobs": "Remove failed jobs",
"require_password_change_on_login": "Require user to change password on first login",
"reset_settings_to_default": "Reset settings to default",
@@ -401,10 +399,6 @@
"transcoding_preferred_hardware_device_description": "Applies only to VAAPI and QSV. Sets the dri node used for hardware transcoding.",
"transcoding_preset_preset": "Preset (-preset)",
"transcoding_preset_preset_description": "Compression speed. Slower presets produce smaller files, and increase quality when targeting a certain bitrate. VP9 ignores speeds above 'faster'.",
"transcoding_realtime": "Real-time Transcoding [EXPERIMENTAL]",
"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_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",
@@ -448,8 +442,6 @@
"user_settings_description": "Manage user settings",
"user_successfully_removed": "User {email} has been successfully removed.",
"users_page_description": "Admin users page",
"version_check_channel": "Release channel",
"version_check_channel_description": "Pick the release channel you want to get version announcements for",
"version_check_enabled_description": "Enable version check",
"version_check_implications": "The version check feature relies on periodic communication with {server}",
"version_check_settings": "Version Check",
@@ -2141,8 +2133,6 @@
"share_assets_selected": "{count} selected",
"share_dialog_preparing": "Preparing...",
"share_link": "Share Link",
"share_original": "Share original",
"share_preview": "Share preview",
"shared": "Shared",
"shared_album_activities_input_disable": "Comment is disabled",
"shared_album_activity_remove_content": "Do you want to delete this activity?",
@@ -2462,7 +2452,6 @@
"video": "Video",
"video_hover_setting": "Play video thumbnail on hover",
"video_hover_setting_description": "Play video thumbnail when mouse is hovering over item. Even when disabled, playback can be started by hovering over the play icon.",
"video_quality": "Video quality",
"videos": "Videos",
"videos_count": "{count, plural, one {# Video} other {# Videos}}",
"videos_only": "Videos only",
@@ -542,16 +542,17 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface NativeSyncApi {
fun shouldFullSync(): Boolean
fun getMediaChanges(): SyncDelta
fun shouldFullSync(callback: (Result<Boolean>) -> Unit)
fun getMediaChanges(callback: (Result<SyncDelta>) -> Unit)
fun checkpointSync()
fun clearSyncCheckpoint()
fun getAssetIdsForAlbum(albumId: String): List<String>
fun getAlbums(): List<PlatformAlbum>
fun getAssetIdsForAlbum(albumId: String, callback: (Result<List<String>>) -> Unit)
fun getAlbums(callback: (Result<List<PlatformAlbum>>) -> Unit)
fun getAssetsCountSince(albumId: String, timestamp: Long): Long
fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?): List<PlatformAsset>
fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?, callback: (Result<List<PlatformAsset>>) -> Unit)
fun hashAssets(assetIds: List<String>, allowNetworkAccess: Boolean, callback: (Result<List<HashResult>>) -> Unit)
fun cancelHashing()
fun cancelSync()
fun getTrashedAssets(): Map<String, List<PlatformAsset>>
fun restoreFromTrashById(mediaId: String, type: Long, callback: (Result<Boolean>) -> Unit)
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult>
@@ -570,27 +571,33 @@ interface NativeSyncApi {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.shouldFullSync$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
listOf(api.shouldFullSync())
} catch (exception: Throwable) {
MessagesPigeonUtils.wrapError(exception)
api.shouldFullSync{ result: Result<Boolean> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(MessagesPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(MessagesPigeonUtils.wrapResult(data))
}
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges$separatedMessageChannelSuffix", codec, taskQueue)
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
listOf(api.getMediaChanges())
} catch (exception: Throwable) {
MessagesPigeonUtils.wrapError(exception)
api.getMediaChanges{ result: Result<SyncDelta> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(MessagesPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(MessagesPigeonUtils.wrapResult(data))
}
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
@@ -629,32 +636,38 @@ interface NativeSyncApi {
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetIdsForAlbum$separatedMessageChannelSuffix", codec, taskQueue)
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetIdsForAlbum$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val albumIdArg = args[0] as String
val wrapped: List<Any?> = try {
listOf(api.getAssetIdsForAlbum(albumIdArg))
} catch (exception: Throwable) {
MessagesPigeonUtils.wrapError(exception)
api.getAssetIdsForAlbum(albumIdArg) { result: Result<List<String>> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(MessagesPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(MessagesPigeonUtils.wrapResult(data))
}
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums$separatedMessageChannelSuffix", codec, taskQueue)
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
listOf(api.getAlbums())
} catch (exception: Throwable) {
MessagesPigeonUtils.wrapError(exception)
api.getAlbums{ result: Result<List<PlatformAlbum>> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(MessagesPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(MessagesPigeonUtils.wrapResult(data))
}
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
@@ -679,18 +692,21 @@ interface NativeSyncApi {
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum$separatedMessageChannelSuffix", codec, taskQueue)
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val albumIdArg = args[0] as String
val updatedTimeCondArg = args[1] as Long?
val wrapped: List<Any?> = try {
listOf(api.getAssetsForAlbum(albumIdArg, updatedTimeCondArg))
} catch (exception: Throwable) {
MessagesPigeonUtils.wrapError(exception)
api.getAssetsForAlbum(albumIdArg, updatedTimeCondArg) { result: Result<List<PlatformAsset>> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(MessagesPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(MessagesPigeonUtils.wrapResult(data))
}
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
@@ -733,6 +749,22 @@ interface NativeSyncApi {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.cancelSync$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
api.cancelSync()
listOf(null)
} catch (exception: Throwable) {
MessagesPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets$separatedMessageChannelSuffix", codec, taskQueue)
if (api != null) {
@@ -4,7 +4,11 @@ import android.content.Context
class NativeSyncApiImpl26(context: Context) : NativeSyncApiImplBase(context), NativeSyncApi {
override fun shouldFullSync(): Boolean {
override fun shouldFullSync(callback: (Result<Boolean>) -> Unit) {
runSync(callback) { shouldFullSync() }
}
private fun shouldFullSync(): Boolean {
return true
}
@@ -18,7 +22,11 @@ class NativeSyncApiImpl26(context: Context) : NativeSyncApiImplBase(context), Na
// No-op for Android 10 and below
}
override fun getMediaChanges(): SyncDelta {
override fun getMediaChanges(callback: (Result<SyncDelta>) -> Unit) {
runSync(callback) { getMediaChanges() }
}
private fun getMediaChanges(): SyncDelta {
throw IllegalStateException("Method not supported on this Android version.")
}
@@ -7,6 +7,8 @@ import android.os.Bundle
import android.provider.MediaStore
import androidx.annotation.RequiresApi
import androidx.annotation.RequiresExtension
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.serialization.json.Json
@RequiresApi(Build.VERSION_CODES.Q)
@@ -35,7 +37,11 @@ class NativeSyncApiImpl30(context: Context) : NativeSyncApiImplBase(context), Na
}
}
override fun shouldFullSync(): Boolean =
override fun shouldFullSync(callback: (Result<Boolean>) -> Unit) {
runSync(callback) { shouldFullSync() }
}
private fun shouldFullSync(): Boolean =
MediaStore.getVersion(ctx) != prefs.getString(SHARED_PREF_MEDIA_STORE_VERSION_KEY, null)
override fun checkpointSync() {
@@ -49,7 +55,11 @@ class NativeSyncApiImpl30(context: Context) : NativeSyncApiImplBase(context), Na
}
}
override fun getMediaChanges(): SyncDelta {
override fun getMediaChanges(callback: (Result<SyncDelta>) -> Unit) {
runSync(callback) { getMediaChanges() }
}
private suspend fun getMediaChanges(): SyncDelta {
val genMap = getSavedGenerationMap()
val currentVolumes = MediaStore.getExternalVolumeNames(ctx)
val changed = mutableListOf<PlatformAsset>()
@@ -58,6 +68,7 @@ class NativeSyncApiImpl30(context: Context) : NativeSyncApiImplBase(context), Na
var hasChanges = genMap.keys != currentVolumes
for (volume in currentVolumes) {
currentCoroutineContext().ensureActive()
val currentGen = MediaStore.getGeneration(ctx, volume)
val storedGen = genMap[volume] ?: 0
if (currentGen <= storedGen) {
@@ -45,12 +45,14 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
private val ctx: Context = context.applicationContext
private var hashTask: Job? = null
private var syncJob: Job? = null
private val mediaTrashDelegate = MediaTrashDelegate(ctx)
companion object {
private const val MAX_CONCURRENT_HASH_OPERATIONS = 16
private val hashSemaphore = Semaphore(MAX_CONCURRENT_HASH_OPERATIONS)
private const val HASHING_CANCELLED_CODE = "HASH_CANCELLED"
private const val SYNC_CANCELLED_CODE = "SYNC_CANCELLED"
// MediaStore.Files.FileColumns.SPECIAL_FORMAT — S Extensions 21+
// https://developer.android.com/reference/android/provider/MediaStore.Files.FileColumns#SPECIAL_FORMAT
@@ -295,7 +297,11 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
return PlatformAssetPlaybackStyle.IMAGE
}
fun getAlbums(): List<PlatformAlbum> {
fun getAlbums(callback: (Result<List<PlatformAlbum>>) -> Unit) {
runSync(callback) { getAlbums() }
}
private suspend fun getAlbums(): List<PlatformAlbum> {
val albums = mutableListOf<PlatformAlbum>()
val albumsCount = mutableMapOf<String, Int>()
@@ -322,6 +328,7 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.DATE_MODIFIED)
while (cursor.moveToNext()) {
currentCoroutineContext().ensureActive()
val id = cursor.getString(bucketIdColumn)
val count = albumsCount.getOrDefault(id, 0)
@@ -342,7 +349,11 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
.sortedBy { it.id }
}
fun getAssetIdsForAlbum(albumId: String): List<String> {
fun getAssetIdsForAlbum(albumId: String, callback: (Result<List<String>>) -> Unit) {
runSync(callback) { getAssetIdsForAlbum(albumId) }
}
private fun getAssetIdsForAlbum(albumId: String): List<String> {
val projection = arrayOf(MediaStore.MediaColumns._ID)
return getCursor(
@@ -366,7 +377,11 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
)?.use { cursor -> cursor.count.toLong() } ?: 0L
fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?): List<PlatformAsset> {
fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?, callback: (Result<List<PlatformAsset>>) -> Unit) {
runSync(callback) { getAssetsForAlbum(albumId, updatedTimeCond) }
}
private fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?): List<PlatformAsset> {
var selection = "$BUCKET_SELECTION AND $MEDIA_SELECTION"
val selectionArgs = mutableListOf(albumId, *MEDIA_SELECTION_ARGS)
@@ -451,6 +466,24 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
hashTask = null
}
fun cancelSync() {
syncJob?.cancel()
syncJob = null
}
protected fun <T> runSync(callback: (Result<T>) -> Unit, work: suspend () -> T) {
syncJob?.cancel()
syncJob = CoroutineScope(Dispatchers.IO).launch {
try {
completeWhenActive(callback, Result.success(work()))
} catch (e: CancellationException) {
completeWhenActive(callback, Result.failure(FlutterError(SYNC_CANCELLED_CODE, "Sync cancelled", null)))
} catch (e: Exception) {
completeWhenActive(callback, Result.failure(e))
}
}
}
fun restoreFromTrashById(mediaId: String, type: Long, callback: (Result<Boolean>) -> Unit) {
mediaTrashDelegate.restoreFromTrashById(mediaId, type) { completeWhenActive(callback, it) }
}
@@ -0,0 +1,154 @@
import 'dart:async';
import 'package:drift/drift.dart' show Value;
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/utils/background_sync.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/main.dart' as app;
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/bootstrap.dart';
import 'package:immich_mobile/wm_executor.dart';
import 'package:integration_test/integration_test.dart';
import 'package:openapi/api.dart';
import 'test_utils/fake_immich_server.dart';
void main() {
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
// These tests do real I/O without pumping a widget tree, so disable the fake async clock
binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive;
late Drift drift;
late FakeImmichServer server;
setUpAll(() async {
await app.initApp();
(drift, _) = await Bootstrap.initDomain();
});
setUp(() async {
await workerManagerPatch.init(dynamicSpawning: true);
server = await FakeImmichServer.start();
await ApiService().resolveAndSetEndpoint(server.endpoint);
await drift.delete(drift.userEntity).go();
await Store.delete(StoreKey.syncMigrationStatus);
});
tearDown(() async {
await workerManagerPatch.dispose();
await server.close();
await Store.delete(StoreKey.serverEndpoint);
await Store.delete(StoreKey.syncMigrationStatus);
});
void sendUser(SyncStream stream, String id, String name) {
stream.send(
type: SyncEntityType.userV1.value,
data: SyncUserV1(
id: id,
name: name,
email: '$id@test.com',
hasProfileImage: false,
deletedAt: null,
profileChangedAt: DateTime.utc(2025),
).toJson(),
ack: id,
);
}
Future<bool> dbReadable() async {
try {
await drift.customSelect('SELECT 1').get().timeout(const Duration(seconds: 5));
return true;
} catch (_) {
return false;
}
}
Future<int> userCount() async => (await drift.select(drift.userEntity).get()).length;
// Starts a remote sync and resolves once its /sync/stream request is open.
Future<(Future<bool>, SyncStream)> startSync() async {
final sync = BackgroundSyncManager().syncRemote();
final stream = await server.streamOpened.timeout(
const Duration(seconds: 30),
onTimeout: () => fail('sync isolate never opened /sync/stream'),
);
return (sync, stream);
}
testWidgets('a full sync ingests streamed events into the shared DB', (tester) async {
expect(await userCount(), 0);
final (sync, stream) = await startSync();
sendUser(stream, 'u1', 'Alice');
sendUser(stream, 'u2', 'Bob');
await stream.close();
final result = await sync.timeout(
const Duration(seconds: 30),
onTimeout: () => fail('sync did not complete after the stream ended'),
);
expect(result, isTrue);
expect(await userCount(), 2);
expect(server.ackRequests, greaterThan(0));
});
testWidgets('disposing the pool during an in-flight sync drains promptly', (tester) async {
final (sync, _) = await startSync();
final sw = Stopwatch()..start();
await workerManagerPatch.dispose().timeout(
const Duration(seconds: 15),
onTimeout: () => fail('dispose() hung — worker did not drain and exit'),
);
expect(sw.elapsed, lessThan(const Duration(seconds: 10)), reason: 'abort-driven, not socket-timeout bound');
expect(await sync.timeout(const Duration(seconds: 5), onTimeout: () => false), isFalse);
});
testWidgets('tearing down a worker blocked mid-write leaves the DB usable', (tester) async {
final (sync, stream) = await startSync();
// Hold an exclusive write transaction so the worker's write is blocked. The lock is taken only
// after the stream opens to avoid blocking the worker's own startup DB reads.
final releaseTxn = Completer<void>();
final txnHeld = Completer<void>();
final txn = drift.transaction(() async {
await drift.into(drift.userEntity).insert(
UserEntityCompanion.insert(
id: 'holder',
name: 'holder',
email: 'holder@test.com',
hasProfileImage: const Value(false),
profileChangedAt: Value(DateTime.utc(2025)),
),
);
txnHeld.complete();
await releaseTxn.future;
});
await txnHeld.future;
sendUser(stream, 'u1', 'Alice');
await stream.close();
// dispose() can only finish once the worker unwinds, which is blocked on the
// lock — so start it, release the lock, then await completion.
final disposed = workerManagerPatch.dispose();
releaseTxn.complete();
await txn;
await disposed.timeout(
const Duration(seconds: 15),
onTimeout: () => fail('dispose() hung after releasing the write lock'),
);
await sync.timeout(const Duration(seconds: 5), onTimeout: () => false);
expect(await dbReadable(), isTrue);
final users = await drift.select(drift.userEntity).get();
expect(users.map((u) => u.id), contains('holder'));
});
}
@@ -0,0 +1,115 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
/// A dummy localhost server that implements only the endpoints that remote-sync touches.
class FakeImmichServer {
FakeImmichServer._(this._server, this.version);
final HttpServer _server;
final (int, int, int) version;
final Completer<SyncStream> _streamOpened = Completer<SyncStream>();
int ackRequests = 0;
String get endpoint => 'http://${_server.address.host}:${_server.port}/api';
/// Resolves when the sync isolate opens `POST /sync/stream`.
Future<SyncStream> get streamOpened => _streamOpened.future;
static Future<FakeImmichServer> start({(int, int, int) version = (3, 0, 0)}) async {
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
final fake = FakeImmichServer._(server, version);
fake._listen();
return fake;
}
void _listen() {
// A connection torn down mid-write during teardown is expected
_server.listen((request) => unawaited(_route(request).catchError((_) {})));
}
Future<void> _route(HttpRequest request) async {
final method = request.method;
final path = request.uri.path;
if (method == 'GET' && path == '/api/server/ping') {
return _respondJson(request, {'res': 'pong'});
}
if (method == 'GET' && path == '/api/server/version') {
final (major, minor, patch) = version;
return _respondJson(request, {'major': major, 'minor': minor, 'patch': patch});
}
if (path == '/api/sync/ack') {
if (method != 'DELETE') {
ackRequests++;
}
return _respondEmpty(request);
}
if (method == 'POST' && path == '/api/sync/stream') {
return _openSyncStream(request);
}
return _respondEmpty(request, status: HttpStatus.notFound);
}
Future<void> _openSyncStream(HttpRequest request) async {
await request.drain<void>();
request.response
..statusCode = HttpStatus.ok
..headers.contentType = ContentType('application', 'jsonlines+json')
..contentLength = -1 // chunked: stays open to stream incrementally
..bufferOutput = false;
// Flush headers so the client's send() resolves and enters its read loop.
await request.response.flush();
if (!_streamOpened.isCompleted) {
_streamOpened.complete(SyncStream._(request.response));
}
}
Future<void> _respondJson(HttpRequest request, Object body) async {
await request.drain<void>();
request.response
..statusCode = HttpStatus.ok
..headers.contentType = ContentType.json
..write(jsonEncode(body));
await request.response.close();
}
Future<void> _respondEmpty(HttpRequest request, {int status = HttpStatus.ok}) async {
await request.drain<void>();
request.response.statusCode = status;
await request.response.close();
}
Future<void> close() async {
if (_streamOpened.isCompleted) {
await (await _streamOpened.future).close();
}
await _server.close(force: true);
}
}
/// Handle to the open `/sync/stream` response: push jsonlines events, then end.
class SyncStream {
SyncStream._(this._response);
final HttpResponse _response;
bool _closed = false;
/// [data] should be a Sync*V1 DTO's `toJson()` so the parser's `fromJson` round-trips it.
void send({required String type, required Object data, required String ack}) {
if (_closed) {
return;
}
_response.write('${jsonEncode({'type': type, 'data': data, 'ack': ack})}\n');
}
Future<void> close() async {
if (_closed) {
return;
}
_closed = true;
await _response.close();
}
}
+79 -93
View File
@@ -9,7 +9,7 @@
/* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
467DA6EAF83F3481F8BD94AB /* Pods_ShareExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8AB817AA297EDEC88B23F3F6 /* Pods_ShareExtension.framework */; };
3B6A31FED0FC846D6BD69BBC /* Pods_ShareExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 357FC57E54FD0F51795CF28A /* Pods_ShareExtension.framework */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
@@ -22,7 +22,7 @@
B2EE00022E72CA15008B6CA7 /* PermissionApi.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2EE00012E72CA15008B6CA7 /* PermissionApi.g.swift */; };
B2EE00042E72CA15008B6CA7 /* PermissionApiImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2EE00032E72CA15008B6CA7 /* PermissionApiImpl.swift */; };
B2BE315F2E5E5229006EEF88 /* BackgroundWorker.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2BE315E2E5E5229006EEF88 /* BackgroundWorker.g.swift */; };
D3BED739C0BC29BB32E18EB2 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CC499FBCE6B29B2DAFED7130 /* Pods_Runner.framework */; };
D218389C4A4C4693F141F7D1 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 886774DBDDE6B35BF2B4F2CD /* Pods_Runner.framework */; };
F02538E92DFBCBDD008C3FA3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
F0B57D3A2DF764BD00DC5BCC /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F0B57D392DF764BD00DC5BCC /* WidgetKit.framework */; };
F0B57D3C2DF764BD00DC5BCC /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F0B57D3B2DF764BD00DC5BCC /* SwiftUI.framework */; };
@@ -85,18 +85,16 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
10B378D23F917891A0F23E33 /* Pods-ShareExtension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareExtension.release.xcconfig"; path = "Target Support Files/Pods-ShareExtension/Pods-ShareExtension.release.xcconfig"; sourceTree = "<group>"; };
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
2E3441B73560D0F6FD25E04F /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
357FC57E54FD0F51795CF28A /* Pods_ShareExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_ShareExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
614A7F5DC5DB09E89E4FCBE8 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
681FBA560D5D2ADDE4F0B59E /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
6D160F04A389B9FFBC557803 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
571EAA93D77181C7C98C2EA6 /* Pods-ShareExtension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareExtension.release.xcconfig"; path = "Target Support Files/Pods-ShareExtension/Pods-ShareExtension.release.xcconfig"; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
8AB817AA297EDEC88B23F3F6 /* Pods_ShareExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_ShareExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; };
937632897A02DE9C249F20A6 /* Pods-ShareExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareExtension.debug.xcconfig"; path = "Target Support Files/Pods-ShareExtension/Pods-ShareExtension.debug.xcconfig"; sourceTree = "<group>"; };
886774DBDDE6B35BF2B4F2CD /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
97C146EE1CF9000F007C117D /* Immich-Debug.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Immich-Debug.app"; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -105,6 +103,7 @@
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
A01DD6982F7F43B40049AB63 /* ImageRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageRequest.swift; sourceTree = "<group>"; };
B1FBA9EE014DE20271B0FE77 /* Pods-ShareExtension.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareExtension.profile.xcconfig"; path = "Target Support Files/Pods-ShareExtension/Pods-ShareExtension.profile.xcconfig"; sourceTree = "<group>"; };
B21E34A92E5AFD210031FDB9 /* BackgroundWorkerApiImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorkerApiImpl.swift; sourceTree = "<group>"; };
B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorker.swift; sourceTree = "<group>"; };
B25D37782E72CA15008B6CA7 /* Connectivity.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Connectivity.g.swift; sourceTree = "<group>"; };
@@ -112,11 +111,12 @@
B2EE00012E72CA15008B6CA7 /* PermissionApi.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionApi.g.swift; sourceTree = "<group>"; };
B2EE00032E72CA15008B6CA7 /* PermissionApiImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionApiImpl.swift; sourceTree = "<group>"; };
B2BE315E2E5E5229006EEF88 /* BackgroundWorker.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorker.g.swift; sourceTree = "<group>"; };
C4A6A71F33CE37B3C913115C /* Pods-ShareExtension.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareExtension.profile.xcconfig"; path = "Target Support Files/Pods-ShareExtension/Pods-ShareExtension.profile.xcconfig"; sourceTree = "<group>"; };
CC499FBCE6B29B2DAFED7130 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
E0E99CDC17B3EB7FA8BA2332 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
F0B57D382DF764BD00DC5BCC /* WidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
F0B57D392DF764BD00DC5BCC /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
F0B57D3B2DF764BD00DC5BCC /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
F7101BB0391A314774615E89 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
F8A35EA3C3E01BD66AFDE0E5 /* Pods-ShareExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareExtension.debug.xcconfig"; path = "Target Support Files/Pods-ShareExtension/Pods-ShareExtension.debug.xcconfig"; sourceTree = "<group>"; };
FA9973382CF6DF4B000EF859 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
FAC6F8902D287C890078CB2F /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
FAC6F8B12D287F120078CB2F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
@@ -199,7 +199,7 @@
FEE084F82EC172460045228E /* SQLiteData in Frameworks */,
FEE084FB2EC1725A0045228E /* RawStructuredFieldValues in Frameworks */,
FEE084FD2EC1725A0045228E /* StructuredFieldValues in Frameworks */,
D3BED739C0BC29BB32E18EB2 /* Pods_Runner.framework in Frameworks */,
D218389C4A4C4693F141F7D1 /* Pods_Runner.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -216,7 +216,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
467DA6EAF83F3481F8BD94AB /* Pods_ShareExtension.framework in Frameworks */,
3B6A31FED0FC846D6BD69BBC /* Pods_ShareExtension.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -226,12 +226,12 @@
0FB772A5B9601143383626CA /* Pods */ = {
isa = PBXGroup;
children = (
614A7F5DC5DB09E89E4FCBE8 /* Pods-Runner.debug.xcconfig */,
6D160F04A389B9FFBC557803 /* Pods-Runner.release.xcconfig */,
681FBA560D5D2ADDE4F0B59E /* Pods-Runner.profile.xcconfig */,
937632897A02DE9C249F20A6 /* Pods-ShareExtension.debug.xcconfig */,
10B378D23F917891A0F23E33 /* Pods-ShareExtension.release.xcconfig */,
C4A6A71F33CE37B3C913115C /* Pods-ShareExtension.profile.xcconfig */,
2E3441B73560D0F6FD25E04F /* Pods-Runner.debug.xcconfig */,
E0E99CDC17B3EB7FA8BA2332 /* Pods-Runner.release.xcconfig */,
F7101BB0391A314774615E89 /* Pods-Runner.profile.xcconfig */,
F8A35EA3C3E01BD66AFDE0E5 /* Pods-ShareExtension.debug.xcconfig */,
571EAA93D77181C7C98C2EA6 /* Pods-ShareExtension.release.xcconfig */,
B1FBA9EE014DE20271B0FE77 /* Pods-ShareExtension.profile.xcconfig */,
);
path = Pods;
sourceTree = "<group>";
@@ -239,10 +239,10 @@
1754452DD81DA6620E279E51 /* Frameworks */ = {
isa = PBXGroup;
children = (
886774DBDDE6B35BF2B4F2CD /* Pods_Runner.framework */,
357FC57E54FD0F51795CF28A /* Pods_ShareExtension.framework */,
F0B57D392DF764BD00DC5BCC /* WidgetKit.framework */,
F0B57D3B2DF764BD00DC5BCC /* SwiftUI.framework */,
CC499FBCE6B29B2DAFED7130 /* Pods_Runner.framework */,
8AB817AA297EDEC88B23F3F6 /* Pods_ShareExtension.framework */,
);
name = Frameworks;
sourceTree = "<group>";
@@ -370,7 +370,7 @@
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
BAEA01ACA3F5C9CD3D732370 /* [CP] Check Pods Manifest.lock */,
4044AF030EF7D8721844FFBA /* [CP] Check Pods Manifest.lock */,
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
@@ -378,8 +378,8 @@
9705A1C41CF9048500538489 /* Embed Frameworks */,
FAC6F89A2D287C890078CB2F /* Embed Foundation Extensions */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
513DA7292DED6106813332F4 /* [CP] Embed Pods Frameworks */,
2FA39DEC809D6D7C4A01EFCB /* [CP] Copy Pods Resources */,
D218A34AEE62BC1EF119F5B0 /* [CP] Embed Pods Frameworks */,
6724EEB7D74949FA08581154 /* [CP] Copy Pods Resources */,
);
buildRules = (
);
@@ -393,9 +393,6 @@
FEE084F22EC172080045228E /* Schemas */,
);
name = Runner;
packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
);
productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Immich-Debug.app */;
productType = "com.apple.product-type.application";
@@ -424,7 +421,7 @@
isa = PBXNativeTarget;
buildConfigurationList = FAC6F8A02D287C890078CB2F /* Build configuration list for PBXNativeTarget "ShareExtension" */;
buildPhases = (
8EC9CF3E20AF32BF24D4F3E1 /* [CP] Check Pods Manifest.lock */,
3BEF3D71D97E337D921C0EB5 /* [CP] Check Pods Manifest.lock */,
FAC6F88C2D287C890078CB2F /* Sources */,
FAC6F88D2D287C890078CB2F /* Frameworks */,
FAC6F88E2D287C890078CB2F /* Resources */,
@@ -473,7 +470,7 @@
);
mainGroup = 97C146E51CF9000F007C117D;
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
FEE084F62EC172460045228E /* XCRemoteSwiftPackageReference "sqlite-data" */,
FEE084F92EC1725A0045228E /* XCRemoteSwiftPackageReference "swift-http-structured-headers" */,
);
@@ -520,23 +517,6 @@
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
2FA39DEC809D6D7C4A01EFCB /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
showEnvVarsInLog = 0;
};
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
@@ -553,24 +533,7 @@
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
513DA7292DED6106813332F4 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
8EC9CF3E20AF32BF24D4F3E1 /* [CP] Check Pods Manifest.lock */ = {
3BEF3D71D97E337D921C0EB5 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
@@ -592,22 +555,7 @@
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Run Script";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build\n";
};
BAEA01ACA3F5C9CD3D732370 /* [CP] Check Pods Manifest.lock */ = {
4044AF030EF7D8721844FFBA /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
@@ -629,6 +577,55 @@
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
6724EEB7D74949FA08581154 /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
showEnvVarsInLog = 0;
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Run Script";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build\n";
};
D218A34AEE62BC1EF119F5B0 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@@ -1095,7 +1092,7 @@
};
FAC6F89C2D287C890078CB2F /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 937632897A02DE9C249F20A6 /* Pods-ShareExtension.debug.xcconfig */;
baseConfigurationReference = F8A35EA3C3E01BD66AFDE0E5 /* Pods-ShareExtension.debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
@@ -1138,7 +1135,7 @@
};
FAC6F89D2D287C890078CB2F /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 10B378D23F917891A0F23E33 /* Pods-ShareExtension.release.xcconfig */;
baseConfigurationReference = 571EAA93D77181C7C98C2EA6 /* Pods-ShareExtension.release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
@@ -1178,7 +1175,7 @@
};
FAC6F89E2D287C890078CB2F /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = C4A6A71F33CE37B3C913115C /* Pods-ShareExtension.profile.xcconfig */;
baseConfigurationReference = B1FBA9EE014DE20271B0FE77 /* Pods-ShareExtension.profile.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
@@ -1261,13 +1258,6 @@
};
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCRemoteSwiftPackageReference section */
FEE084F62EC172460045228E /* XCRemoteSwiftPackageReference "sqlite-data" */ = {
isa = XCRemoteSwiftPackageReference;
@@ -1288,10 +1278,6 @@
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency;
productName = FlutterGeneratedPluginSwiftPackage;
};
FEE084F72EC172460045228E /* SQLiteData */ = {
isa = XCSwiftPackageProductDependency;
package = FEE084F62EC172460045228E /* XCRemoteSwiftPackageReference "sqlite-data" */;
@@ -5,8 +5,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/combine-schedulers",
"state" : {
"revision" : "fd16d76fd8b9a976d88bfb6cacc05ca8d19c91b6",
"version" : "1.1.0"
"revision" : "5928286acce13def418ec36d05a001a9641086f2",
"version" : "1.0.3"
}
},
{
@@ -121,8 +121,8 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
/**
* Cancels the currently running background task, either due to timeout or external request.
* Sends a cancel signal to the Flutter side and sets up a fallback timer to ensure
* the completion handler is eventually called even if Flutter doesn't respond.
* Only tears down the engine after Dart confirms it's drained. If Dart overruns iOS's grace window,
* the expiration handler still calls setTaskCompleted and iOS suspends us.
*/
func close() {
if isComplete {
@@ -132,12 +132,6 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
flutterApi?.cancel { result in
self.complete(success: false)
}
// Fallback safety mechanism: ensure completion is called within 2 seconds
// This prevents the background task from hanging indefinitely if Flutter doesn't respond
Timer.scheduledTimer(withTimeInterval: 2, repeats: false) { _ in
self.complete(success: false)
}
}
+58 -42
View File
@@ -526,16 +526,17 @@ class MessagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol NativeSyncApi {
func shouldFullSync() throws -> Bool
func getMediaChanges() throws -> SyncDelta
func shouldFullSync(completion: @escaping (Result<Bool, Error>) -> Void)
func getMediaChanges(completion: @escaping (Result<SyncDelta, Error>) -> Void)
func checkpointSync() throws
func clearSyncCheckpoint() throws
func getAssetIdsForAlbum(albumId: String) throws -> [String]
func getAlbums() throws -> [PlatformAlbum]
func getAssetIdsForAlbum(albumId: String, completion: @escaping (Result<[String], Error>) -> Void)
func getAlbums(completion: @escaping (Result<[PlatformAlbum], Error>) -> Void)
func getAssetsCountSince(albumId: String, timestamp: Int64) throws -> Int64
func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset]
func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?, completion: @escaping (Result<[PlatformAsset], Error>) -> Void)
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void)
func cancelHashing() throws
func cancelSync() throws
func getTrashedAssets() throws -> [String: [PlatformAsset]]
func restoreFromTrashById(mediaId: String, type: Int64, completion: @escaping (Result<Bool, Error>) -> Void)
func getCloudIdForAssetIds(assetIds: [String]) throws -> [CloudIdResult]
@@ -555,26 +556,28 @@ class NativeSyncApiSetup {
let shouldFullSyncChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.shouldFullSync\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
shouldFullSyncChannel.setMessageHandler { _, reply in
do {
let result = try api.shouldFullSync()
reply(wrapResult(result))
} catch {
reply(wrapError(error))
api.shouldFullSync { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
shouldFullSyncChannel.setMessageHandler(nil)
}
let getMediaChangesChannel = taskQueue == nil
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
let getMediaChangesChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
getMediaChangesChannel.setMessageHandler { _, reply in
do {
let result = try api.getMediaChanges()
reply(wrapResult(result))
} catch {
reply(wrapError(error))
api.getMediaChanges { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
@@ -606,33 +609,33 @@ class NativeSyncApiSetup {
} else {
clearSyncCheckpointChannel.setMessageHandler(nil)
}
let getAssetIdsForAlbumChannel = taskQueue == nil
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetIdsForAlbum\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetIdsForAlbum\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
let getAssetIdsForAlbumChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetIdsForAlbum\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
getAssetIdsForAlbumChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let albumIdArg = args[0] as! String
do {
let result = try api.getAssetIdsForAlbum(albumId: albumIdArg)
reply(wrapResult(result))
} catch {
reply(wrapError(error))
api.getAssetIdsForAlbum(albumId: albumIdArg) { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
getAssetIdsForAlbumChannel.setMessageHandler(nil)
}
let getAlbumsChannel = taskQueue == nil
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
let getAlbumsChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
getAlbumsChannel.setMessageHandler { _, reply in
do {
let result = try api.getAlbums()
reply(wrapResult(result))
} catch {
reply(wrapError(error))
api.getAlbums { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
@@ -656,19 +659,19 @@ class NativeSyncApiSetup {
} else {
getAssetsCountSinceChannel.setMessageHandler(nil)
}
let getAssetsForAlbumChannel = taskQueue == nil
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
let getAssetsForAlbumChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
getAssetsForAlbumChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let albumIdArg = args[0] as! String
let updatedTimeCondArg: Int64? = nilOrValue(args[1])
do {
let result = try api.getAssetsForAlbum(albumId: albumIdArg, updatedTimeCond: updatedTimeCondArg)
reply(wrapResult(result))
} catch {
reply(wrapError(error))
api.getAssetsForAlbum(albumId: albumIdArg, updatedTimeCond: updatedTimeCondArg) { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
@@ -707,6 +710,19 @@ class NativeSyncApiSetup {
} else {
cancelHashingChannel.setMessageHandler(nil)
}
let cancelSyncChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.cancelSync\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
cancelSyncChannel.setMessageHandler { _, reply in
do {
try api.cancelSync()
reply(wrapResult(nil))
} catch {
reply(wrapError(error))
}
}
} else {
cancelSyncChannel.setMessageHandler(nil)
}
let getTrashedAssetsChannel = taskQueue == nil
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
+102 -45
View File
@@ -39,6 +39,9 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
private static let hashCancelledCode = "HASH_CANCELLED"
private static let hashCancelled = Result<[HashResult], Error>.failure(PigeonError(code: hashCancelledCode, message: "Hashing cancelled", details: nil))
private var syncTask: Task<Void?, Error>?
private static let syncCancelledCode = "SYNC_CANCELLED"
private static let syncCancelled = PigeonError(code: syncCancelledCode, message: "Sync cancelled", details: nil)
init(with defaults: UserDefaults = .standard) {
self.defaults = defaults
@@ -71,7 +74,11 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
saveChangeToken(token: PHPhotoLibrary.shared().currentChangeToken)
}
func shouldFullSync() -> Bool {
func shouldFullSync(completion: @escaping (Result<Bool, Error>) -> Void) {
runSync(completion) { $0.shouldFullSync() }
}
private func shouldFullSync() -> Bool {
guard #available(iOS 16, *),
PHPhotoLibrary.authorizationStatus(for: .readWrite) == .authorized,
let storedToken = getChangeToken() else {
@@ -87,12 +94,17 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
return false
}
func getAlbums() throws -> [PlatformAlbum] {
func getAlbums(completion: @escaping (Result<[PlatformAlbum], Error>) -> Void) {
runSync(completion) { try $0.getAlbums() }
}
private func getAlbums() throws -> [PlatformAlbum] {
var albums: [PlatformAlbum] = []
albumTypes.forEach { type in
for type in albumTypes {
let collections = PHAssetCollection.fetchAssetCollections(with: type, subtype: .any, options: nil)
for i in 0..<collections.count {
try Task.checkCancellation()
let album = collections.object(at: i)
// Ignore recovered album
@@ -126,7 +138,11 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
return albums.sorted { $0.id < $1.id }
}
func getMediaChanges() throws -> SyncDelta {
func getMediaChanges(completion: @escaping (Result<SyncDelta, Error>) -> Void) {
runSync(completion) { try $0.getMediaChanges() }
}
private func getMediaChanges() throws -> SyncDelta {
guard #available(iOS 16, *) else {
throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature requires iOS 16 or later.", details: nil)
}
@@ -146,51 +162,49 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
return SyncDelta(hasChanges: false, updates: [], deletes: [], assetAlbums: [:])
}
do {
let changes = try PHPhotoLibrary.shared().fetchPersistentChanges(since: storedToken)
let changes = try PHPhotoLibrary.shared().fetchPersistentChanges(since: storedToken)
var updatedAssets: Set<AssetWrapper> = []
var deletedAssets: Set<String> = []
for change in changes {
try Task.checkCancellation()
guard let details = try? change.changeDetails(for: PHObjectType.asset) else { continue }
var updatedAssets: Set<AssetWrapper> = []
var deletedAssets: Set<String> = []
let updated = details.updatedLocalIdentifiers.union(details.insertedLocalIdentifiers)
deletedAssets.formUnion(details.deletedLocalIdentifiers)
for change in changes {
guard let details = try? change.changeDetails(for: PHObjectType.asset) else { continue }
if (updated.isEmpty) { continue }
let options = PHFetchOptions()
options.includeHiddenAssets = false
let result = PHAsset.fetchAssets(withLocalIdentifiers: Array(updated), options: options)
for i in 0..<result.count {
let asset = result.object(at: i)
let updated = details.updatedLocalIdentifiers.union(details.insertedLocalIdentifiers)
deletedAssets.formUnion(details.deletedLocalIdentifiers)
if (updated.isEmpty) { continue }
let options = PHFetchOptions()
options.includeHiddenAssets = false
let result = PHAsset.fetchAssets(withLocalIdentifiers: Array(updated), options: options)
for i in 0..<result.count {
let asset = result.object(at: i)
// Asset wrapper only uses the id for comparison. Multiple change can contain the same asset, skip duplicate changes
let predicate = PlatformAsset(
id: asset.localIdentifier,
name: "",
type: 0,
durationMs: 0,
orientation: 0,
isFavorite: false,
playbackStyle: .unknown
)
if (updatedAssets.contains(AssetWrapper(with: predicate))) {
continue
}
let domainAsset = AssetWrapper(with: asset.toPlatformAsset())
updatedAssets.insert(domainAsset)
// Asset wrapper only uses the id for comparison. Multiple change can contain the same asset, skip duplicate changes
let predicate = PlatformAsset(
id: asset.localIdentifier,
name: "",
type: 0,
durationMs: 0,
orientation: 0,
isFavorite: false,
playbackStyle: .unknown
)
if (updatedAssets.contains(AssetWrapper(with: predicate))) {
continue
}
let domainAsset = AssetWrapper(with: asset.toPlatformAsset())
updatedAssets.insert(domainAsset)
}
let updates = Array(updatedAssets.map { $0.asset })
return SyncDelta(hasChanges: true, updates: updates, deletes: Array(deletedAssets), assetAlbums: buildAssetAlbumsMap(assets: updates))
}
let updates = Array(updatedAssets.map { $0.asset })
return SyncDelta(hasChanges: true, updates: updates, deletes: Array(deletedAssets), assetAlbums: buildAssetAlbumsMap(assets: updates))
}
private func buildAssetAlbumsMap(assets: Array<PlatformAsset>) -> [String: [String]] {
guard !assets.isEmpty else {
return [:]
@@ -213,7 +227,11 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
return albumAssets
}
func getAssetIdsForAlbum(albumId: String) throws -> [String] {
func getAssetIdsForAlbum(albumId: String, completion: @escaping (Result<[String], Error>) -> Void) {
runSync(completion) { try $0.getAssetIdsForAlbum(albumId: albumId) }
}
private func getAssetIdsForAlbum(albumId: String) throws -> [String] {
let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
guard let album = collections.firstObject else {
return []
@@ -223,9 +241,14 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
let options = PHFetchOptions()
options.includeHiddenAssets = false
let assets = getAssetsFromAlbum(in: album, options: options)
assets.enumerateObjects { (asset, _, _) in
assets.enumerateObjects { (asset, _, stop) in
if Task.isCancelled {
stop.pointee = true
return
}
ids.append(asset.localIdentifier)
}
try Task.checkCancellation()
return ids
}
@@ -243,7 +266,11 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
return Int64(assets.count)
}
func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset] {
func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?, completion: @escaping (Result<[PlatformAsset], Error>) -> Void) {
runSync(completion) { try $0.getAssetsForAlbum(albumId: albumId, updatedTimeCond: updatedTimeCond) }
}
private func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset] {
let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
guard let album = collections.firstObject else {
return []
@@ -262,9 +289,14 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
}
var assets: [PlatformAsset] = []
result.enumerateObjects { (asset, _, _) in
result.enumerateObjects { (asset, _, stop) in
if Task.isCancelled {
stop.pointee = true
return
}
assets.append(asset.toPlatformAsset())
}
try Task.checkCancellation()
return assets
}
@@ -324,6 +356,31 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
hashTask = nil
}
func cancelSync() {
syncTask?.cancel()
syncTask = nil
}
private func runSync<T>(
_ completion: @escaping (Result<T, Error>) -> Void,
_ work: @escaping (NativeSyncApiImpl) throws -> T
) {
syncTask?.cancel()
syncTask = Task { [weak self] in
guard let self else { return nil }
let result: Result<T, Error>
do {
result = .success(try work(self))
} catch is CancellationError {
result = .failure(Self.syncCancelled)
} catch {
result = .failure(error)
}
self.completeWhenActive(for: completion, with: result)
return nil
}
}
private func hashAsset(_ asset: PHAsset, allowNetworkAccess: Bool) async -> HashResult? {
class RequestRef {
var id: PHAssetResourceDataRequestID?
+1 -1
View File
@@ -49,7 +49,7 @@ def get_version_from_pubspec
pubspec = YAML.load_file(pubspec_path)
version_string = pubspec['version']
version_string ? version_string.split('+').first.split('-').first : nil
version_string ? version_string.split('+').first : nil
end
# Helper method to configure code signing for all targets
-2
View File
@@ -13,8 +13,6 @@ enum AssetVisibilityEnum { timeline, hidden, archive, locked }
enum ActionSource { timeline, viewer }
enum ShareAssetFileType { original, preview }
enum CleanupStep { selectDate, scan, delete }
enum AssetKeepType { none, photosOnly, videosOnly }
@@ -188,20 +188,14 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
if (!_cancellationToken.isCompleted) {
_cancellationToken.complete();
}
final cleanupFutures = [
nativeSyncApi?.cancelHashing(),
workerManagerPatch.dispose().catchError((_) async {
// Discard any errors on the dispose call
return;
}),
LogService.I.dispose(),
Store.dispose(),
backgroundSyncManager?.cancel(),
_drift.optimize(allTables: true),
];
await Future.wait(cleanupFutures.nonNulls);
// Workers share one sqlite connection, so DB teardown must wait until every worker has stopped using it.
await Future.wait([
if (backgroundSyncManager != null) backgroundSyncManager.cancel(),
if (nativeSyncApi != null) nativeSyncApi.cancelHashing(),
]);
await workerManagerPatch.dispose().catchError((_) async {});
await Future.wait([LogService.I.dispose(), Store.dispose(), _drift.optimize(allTables: true)]);
await _drift.close();
await _driftLogger.close();
+10 -4
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/services.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
@@ -17,7 +19,7 @@ class HashService {
final DriftLocalAssetRepository _localAssetRepository;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final NativeSyncApi _nativeSyncApi;
final bool Function()? _cancelChecker;
final Completer<void>? _cancellation;
final _log = Logger('HashService');
HashService({
@@ -25,11 +27,15 @@ class HashService {
required this._localAssetRepository,
required this._trashedLocalAssetRepository,
required this._nativeSyncApi,
this._cancelChecker,
this._cancellation,
int? batchSize,
}) : _batchSize = batchSize ?? kBatchHashFileLimit;
}) : _batchSize = batchSize ?? kBatchHashFileLimit {
// Stop the in-flight native hash call promptly on cancellation; the loops
// below also observe [isCancelled] to bail between batches.
_cancellation?.future.then((_) => _nativeSyncApi.cancelHashing().onError(_log.warning));
}
bool get isCancelled => _cancelChecker?.call() ?? false;
bool get isCancelled => _cancellation?.isCompleted ?? false;
Future<void> hashAssets() async {
_log.info("Starting hashing of assets");
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
@@ -17,6 +18,8 @@ import 'package:immich_mobile/utils/datetime_helpers.dart';
import 'package:immich_mobile/utils/diff.dart';
import 'package:logging/logging.dart';
const String _kSyncCancelledCode = "SYNC_CANCELLED";
class LocalSyncService {
final DriftLocalAlbumRepository _localAlbumRepository;
// ignore: unused_field
@@ -25,6 +28,7 @@ class LocalSyncService {
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final AssetMediaRepository _assetMediaRepository;
final IPermissionRepository _permissionRepository;
final Completer<void>? _cancellation;
final Logger _log = Logger("DeviceSyncService");
LocalSyncService({
@@ -34,7 +38,12 @@ class LocalSyncService {
required this._trashedLocalAssetRepository,
required this._assetMediaRepository,
required this._permissionRepository,
});
this._cancellation,
}) {
_cancellation?.future.then((_) => _nativeSyncApi.cancelSync().onError(_log.warning));
}
bool get _isCancelled => _cancellation?.isCompleted ?? false;
Future<void> sync({bool full = false}) async {
final Stopwatch stopwatch = Stopwatch()..start();
@@ -81,6 +90,10 @@ class LocalSyncService {
// detect album deletions from the native side
if (CurrentPlatform.isAndroid) {
for (final album in dbAlbums) {
if (_isCancelled) {
_log.warning("Local sync cancelled. Stopped processing albums.");
return;
}
final deviceIds = await _nativeSyncApi.getAssetIdsForAlbum(album.id);
await _localAlbumRepository.syncDeletes(album.id, deviceIds);
}
@@ -91,6 +104,10 @@ class LocalSyncService {
// does not include changes for cloud albums.
final cloudAlbums = deviceAlbums.where((a) => a.isCloud).toLocalAlbums();
for (final album in cloudAlbums) {
if (_isCancelled) {
_log.warning("Local sync cancelled. Stopped processing cloud albums.");
return;
}
final dbAlbum = dbAlbums.firstWhereOrNull((a) => a.id == album.id);
if (dbAlbum == null) {
_log.warning("Cloud album ${album.name} not found in local database. Skipping sync.");
@@ -102,6 +119,12 @@ class LocalSyncService {
await _mapIosCloudIds(newAssets);
}
await _nativeSyncApi.checkpointSync();
} on PlatformException catch (e, s) {
if (e.code == _kSyncCancelledCode) {
_log.warning("Local sync cancelled");
} else {
_log.severe("Error performing device sync", e, s);
}
} catch (e, s) {
_log.severe("Error performing device sync", e, s);
} finally {
@@ -129,12 +152,21 @@ class LocalSyncService {
await _nativeSyncApi.checkpointSync();
stopwatch.stop();
_log.info("Full device sync took - ${stopwatch.elapsedMilliseconds}ms");
} on PlatformException catch (e, s) {
if (e.code == _kSyncCancelledCode) {
_log.warning("Full device sync cancelled");
} else {
_log.severe("Error performing full device sync", e, s);
}
} catch (e, s) {
_log.severe("Error performing full device sync", e, s);
}
}
Future<void> addAlbum(LocalAlbum album) async {
if (_isCancelled) {
return;
}
try {
_log.fine("Adding device album ${album.name}");
@@ -162,6 +194,9 @@ class LocalSyncService {
// The deviceAlbum is ignored since we are going to refresh it anyways
FutureOr<bool> updateAlbum(LocalAlbum dbAlbum, LocalAlbum deviceAlbum) async {
if (_isCancelled) {
return false;
}
try {
_log.fine("Syncing device album ${dbAlbum.name}");
+9 -3
View File
@@ -112,10 +112,16 @@ class LogService {
return _flushBuffer();
}
Future<void> dispose() {
Future<void> dispose() async {
_flushTimer?.cancel();
_logSubscription.cancel();
return _flushBuffer();
_flushTimer = null;
await _logSubscription.cancel();
await _flushBuffer();
// Allow a subsequent init() (e.g. when a worker isolate is reused) to
// create a fresh instance instead of returning this disposed one.
if (identical(_instance, this)) {
_instance = null;
}
}
Future<void> _flushBuffer() async {
@@ -54,7 +54,13 @@ class StoreService {
/// Disposes the store and cancels the subscription. To reuse the store call init() again
Future<void> dispose() async {
await _storeUpdateSubscription?.cancel();
_storeUpdateSubscription = null;
_cache.clear();
// Allow a subsequent init() (e.g. when a worker isolate is reused) to
// create a fresh instance instead of returning this disposed one.
if (identical(_instance, this)) {
_instance = null;
}
}
/// Returns the cached value for [key], or `null`
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
@@ -5,6 +7,7 @@ import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart';
import 'package:immich_mobile/providers/infrastructure/store.provider.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:immich_mobile/utils/debug_print.dart';
@@ -16,6 +19,7 @@ final syncLinkedAlbumServiceProvider = Provider(
ref.watch(remoteAlbumRepository),
ref.watch(driftAlbumApiRepositoryProvider),
ref.watch(storeServiceProvider),
cancellation: ref.watch(cancellationProvider),
),
);
@@ -24,13 +28,15 @@ class SyncLinkedAlbumService {
final DriftRemoteAlbumRepository _remoteAlbumRepository;
final DriftAlbumApiRepository _albumApiRepository;
final StoreService _storeService;
final Completer<void>? _cancellation;
SyncLinkedAlbumService(
this._localAlbumRepository,
this._remoteAlbumRepository,
this._albumApiRepository,
this._storeService,
);
this._storeService, {
this._cancellation,
});
final _log = Logger("SyncLinkedAlbumService");
@@ -55,7 +61,11 @@ class SyncLinkedAlbumService {
final assetIds = await _remoteAlbumRepository.getLinkedAssetIds(userId, localAlbum.id, linkedRemoteAlbumId);
_log.fine("Syncing ${assetIds.length} assets to remote album: ${remoteAlbum.name}");
if (assetIds.isNotEmpty) {
final album = await _albumApiRepository.addAssets(remoteAlbum.id, assetIds);
final album = await _albumApiRepository.addAssets(
remoteAlbum.id,
assetIds,
abortTrigger: _cancellation?.future,
);
await _remoteAlbumRepository.addAssets(remoteAlbum.id, album.added);
}
}),
@@ -38,7 +38,7 @@ class SyncStreamService {
final IPermissionRepository _permissionRepository;
final SyncMigrationRepository _syncMigrationRepository;
final ApiService _api;
final bool Function()? _cancelChecker;
final Completer<void>? _cancellation;
SyncStreamService({
required this._syncApiRepository,
@@ -49,10 +49,10 @@ class SyncStreamService {
required this._permissionRepository,
required this._syncMigrationRepository,
required this._api,
this._cancelChecker,
this._cancellation,
});
bool get isCancelled => _cancelChecker?.call() ?? false;
bool get isCancelled => _cancellation?.isCompleted ?? false;
Future<bool> sync() async {
_logger.info("Remote sync request for user");
@@ -80,10 +80,15 @@ class SyncStreamService {
_handleEvents,
serverVersion: serverSemVer,
onReset: () => shouldReset = true,
abortSignal: _cancellation?.future,
);
if (shouldReset) {
_logger.info("Resetting sync state as requested by server");
await _syncApiRepository.streamChanges(_handleEvents, serverVersion: serverSemVer);
await _syncApiRepository.streamChanges(
_handleEvents,
serverVersion: serverSemVer,
abortSignal: _cancellation?.future,
);
}
previousLength = migrations.length;
@@ -318,7 +323,7 @@ class SyncStreamService {
}
Future<void> handleWsAssetUploadReadyV1Batch(List<dynamic> batchData) async {
if (batchData.isEmpty) {
if (batchData.isEmpty || isCancelled) {
return;
}
@@ -361,7 +366,7 @@ class SyncStreamService {
}
Future<void> handleWsAssetUploadReadyV2Batch(List<dynamic> batchData) async {
if (batchData.isEmpty) {
if (batchData.isEmpty || isCancelled) {
return;
}
@@ -404,6 +409,9 @@ class SyncStreamService {
}
Future<void> handleWsAssetEditReadyV1(dynamic data) async {
if (isCancelled) {
return;
}
_logger.info('Processing AssetEditReadyV1 event');
try {
@@ -444,6 +452,9 @@ class SyncStreamService {
}
Future<void> handleWsAssetEditReadyV2(dynamic data) async {
if (isCancelled) {
return;
}
_logger.info('Processing AssetEditReadyV2 event');
try {
+15 -41
View File
@@ -50,53 +50,27 @@ class BackgroundSyncManager {
});
Future<void> cancel() async {
final futures = <Future>[];
if (_syncTask != null) {
futures.add(_syncTask!.future);
final tasks = [
_syncTask,
_syncWebsocketTask,
_cloudIdSyncTask,
_linkedAlbumSyncTask,
_deviceAlbumSyncTask,
_hashTask,
];
final futures = [
for (final task in tasks)
if (task != null) task.future,
];
for (final task in tasks) {
task?.cancel();
}
_syncTask?.cancel();
_syncTask = null;
if (_syncWebsocketTask != null) {
futures.add(_syncWebsocketTask!.future);
}
_syncWebsocketTask?.cancel();
_syncWebsocketTask = null;
if (_cloudIdSyncTask != null) {
futures.add(_cloudIdSyncTask!.future);
}
_cloudIdSyncTask?.cancel();
_cloudIdSyncTask = null;
if (_linkedAlbumSyncTask != null) {
futures.add(_linkedAlbumSyncTask!.future);
}
_linkedAlbumSyncTask?.cancel();
_linkedAlbumSyncTask = null;
try {
await Future.wait(futures);
} on CanceledError {
// Ignore cancellation errors
}
}
Future<void> cancelLocal() async {
final futures = <Future>[];
if (_hashTask != null) {
futures.add(_hashTask!.future);
}
_hashTask?.cancel();
_hashTask = null;
if (_deviceAlbumSyncTask != null) {
futures.add(_deviceAlbumSyncTask!.future);
}
_deviceAlbumSyncTask?.cancel();
_deviceAlbumSyncTask = null;
_hashTask = null;
try {
await Future.wait(futures);
+31 -7
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:drift/drift.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart';
@@ -9,6 +11,7 @@ import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/sync.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
@@ -51,9 +54,10 @@ Future<void> syncCloudIds(ProviderContainer ref) async {
}
final assetApi = ref.read(apiServiceProvider).assetsApi;
final cancellation = ref.read(cancellationProvider);
// Process cloud IDs in paginated batches
await _processCloudIdMappingsInBatches(db, currentUser.id, assetApi, canBulkUpdateMetadata, logger);
await _processCloudIdMappingsInBatches(db, currentUser.id, assetApi, canBulkUpdateMetadata, logger, cancellation);
}
Future<void> _processCloudIdMappingsInBatches(
@@ -62,12 +66,17 @@ Future<void> _processCloudIdMappingsInBatches(
AssetsApi assetsApi,
bool canBulkUpdate,
Logger logger,
Completer<void> cancellation,
) async {
const pageSize = 20000;
String? lastLocalId;
final seenRemoteAssetIds = <String>{};
while (true) {
if (cancellation.isCompleted) {
logger.warning('Cloud ID migration cancelled. Stopping batch processing.');
break;
}
final mappings = await _fetchCloudIdMappings(drift, userId, pageSize, lastLocalId);
if (mappings.isEmpty) {
break;
@@ -98,9 +107,9 @@ Future<void> _processCloudIdMappingsInBatches(
if (items.isNotEmpty) {
if (canBulkUpdate) {
await _bulkUpdateCloudIds(assetsApi, items);
await _bulkUpdateCloudIds(assetsApi, items, cancellation.future);
} else {
await _sequentialUpdateCloudIds(assetsApi, items);
await _sequentialUpdateCloudIds(assetsApi, items, cancellation);
}
}
@@ -111,20 +120,35 @@ Future<void> _processCloudIdMappingsInBatches(
}
}
Future<void> _sequentialUpdateCloudIds(AssetsApi assetsApi, List<AssetMetadataBulkUpsertItemDto> items) async {
Future<void> _sequentialUpdateCloudIds(
AssetsApi assetsApi,
List<AssetMetadataBulkUpsertItemDto> items,
Completer<void> cancellation,
) async {
for (final item in items) {
if (cancellation.isCompleted) {
break;
}
final upsertItem = AssetMetadataUpsertItemDto(key: item.key, value: item.value);
try {
await assetsApi.updateAssetMetadata(item.assetId, AssetMetadataUpsertDto(items: [upsertItem]));
await assetsApi.updateAssetMetadata(
item.assetId,
AssetMetadataUpsertDto(items: [upsertItem]),
abortTrigger: cancellation.future,
);
} catch (error, stack) {
Logger('migrateCloudIds').warning('Failed to update metadata for asset ${item.assetId}', error, stack);
}
}
}
Future<void> _bulkUpdateCloudIds(AssetsApi assetsApi, List<AssetMetadataBulkUpsertItemDto> items) async {
Future<void> _bulkUpdateCloudIds(
AssetsApi assetsApi,
List<AssetMetadataBulkUpsertItemDto> items,
Future<void> abortTrigger,
) async {
try {
await assetsApi.updateBulkAssetMetadata(AssetMetadataBulkUpsertDto(items: items));
await assetsApi.updateBulkAssetMetadata(AssetMetadataBulkUpsertDto(items: items), abortTrigger: abortTrigger);
} catch (error, stack) {
Logger('migrateCloudIds').warning('Failed to bulk update metadata', error, stack);
}
@@ -29,6 +29,7 @@ class SyncApiRepository {
Function()? onReset,
int batchSize = kSyncEventBatchSize,
http.Client? httpClient,
Future<void>? abortSignal,
}) async {
final stopwatch = Stopwatch()..start();
final client = httpClient ?? NetworkRepository.client;
@@ -36,7 +37,7 @@ class SyncApiRepository {
final headers = {'Content-Type': 'application/json', 'Accept': 'application/jsonlines+json'};
final request = http.Request('POST', Uri.parse(endpoint));
final request = http.AbortableRequest('POST', Uri.parse(endpoint), abortTrigger: abortSignal);
request.headers.addAll(headers);
request.body = jsonEncode(
SyncStreamDto(
@@ -2,12 +2,16 @@ import 'package:immich_mobile/utils/semver.dart';
import 'package:openapi/api.dart';
class ServerVersion extends SemVer {
const ServerVersion({required super.major, required super.minor, required super.patch, super.prerelease});
const ServerVersion({required super.major, required super.minor, required super.patch});
ServerVersion.fromDto(ServerVersionResponseDto dto)
: super(major: dto.major, minor: dto.minor, patch: dto.patch_, prerelease: dto.prerelease);
@override
String toString() {
return 'ServerVersion(major: $major, minor: $minor, patch: $patch)';
}
bool isAtLeast({int major = 0, int minor = 0, int patch = 0, int? prerelease}) {
return this >= SemVer(major: major, minor: minor, patch: patch, prerelease: prerelease);
ServerVersion.fromDto(ServerVersionResponseDto dto) : super(major: dto.major, minor: dto.minor, patch: dto.patch_);
bool isAtLeast({int major = 0, int minor = 0, int patch = 0}) {
return this >= SemVer(major: major, minor: minor, patch: patch);
}
}
+14
View File
@@ -635,6 +635,20 @@ class NativeSyncApi {
_extractReplyValueOrThrow(pigeonVar_replyList, pigeonVar_channelName, isNullValid: true);
}
Future<void> cancelSync() async {
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.cancelSync$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
_extractReplyValueOrThrow(pigeonVar_replyList, pigeonVar_channelName, isNullValid: true);
}
Future<Map<String, List<PlatformAsset>>> getTrashedAssets() async {
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets$pigeonVar_messageChannelSuffix';
@@ -48,33 +48,6 @@ class _SharePreparingDialog extends StatelessWidget {
}
}
class _ShareFileTypeDialog extends StatelessWidget {
const _ShareFileTypeDialog();
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text('share'.t(context: context)),
contentPadding: const EdgeInsets.symmetric(vertical: 8),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.high_quality_rounded),
title: Text('share_original'.t(context: context)),
onTap: () => context.pop(ShareAssetFileType.original),
),
ListTile(
leading: const Icon(Icons.photo_size_select_large_rounded),
title: Text('share_preview'.t(context: context)),
onTap: () => context.pop(ShareAssetFileType.preview),
),
],
),
);
}
}
class ShareActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
@@ -87,15 +60,6 @@ class ShareActionButton extends ConsumerWidget {
return;
}
final fileType = await showDialog<ShareAssetFileType>(
context: context,
builder: (_) => const _ShareFileTypeDialog(),
useRootNavigator: false,
);
if (fileType == null || !context.mounted) {
return;
}
final cancelCompleter = Completer<void>();
final progress = ValueNotifier<double?>(null);
final preparingDialog = _SharePreparingDialog(progress: progress);
@@ -107,7 +71,6 @@ class ShareActionButton extends ConsumerWidget {
.shareAssets(
source,
context,
fileType: fileType,
cancelCompleter: cancelCompleter,
onAssetDownloadProgress: (value) => progress.value = value,
)
@@ -513,21 +513,19 @@ class ActionNotifier extends Notifier<void> {
Future<ActionResult> shareAssets(
ActionSource source,
BuildContext context, {
ShareAssetFileType fileType = ShareAssetFileType.original,
Completer<void>? cancelCompleter,
void Function(double progress)? onAssetDownloadProgress,
}) async {
final ids = _getAssets(source).toList(growable: false);
try {
final count = await _service.shareAssets(
await _service.shareAssets(
ids,
context,
fileType: fileType,
cancelCompleter: cancelCompleter,
onAssetDownloadProgress: onAssetDownloadProgress,
);
return ActionResult(count: count, success: count > 0 || ids.isEmpty);
return ActionResult(count: ids.length, success: true);
} catch (error, stack) {
_logger.severe('Failed to share assets', error, stack);
return ActionResult(count: ids.length, success: false, error: error.toString());
@@ -1,8 +1,9 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
/// Provider holding a boolean function that returns true when cancellation is requested.
/// A computation running in the isolate uses the function to implement cooperative cancellation.
final cancellationProvider = Provider<bool Function()>(
/// Holds the isolate's cancellation signal.
final cancellationProvider = Provider<Completer<void>>(
// This will be overridden in the isolate's container.
// Throwing ensures it's not used without an override.
(ref) => throw UnimplementedError(
@@ -26,7 +26,7 @@ final syncStreamServiceProvider = Provider(
permissionRepository: ref.watch(permissionRepositoryProvider),
syncMigrationRepository: ref.watch(syncMigrationRepositoryProvider),
api: ref.watch(apiServiceProvider),
cancelChecker: ref.watch(cancellationProvider),
cancellation: ref.watch(cancellationProvider),
),
);
@@ -42,6 +42,7 @@ final localSyncServiceProvider = Provider(
assetMediaRepository: ref.watch(assetMediaRepositoryProvider),
permissionRepository: ref.watch(permissionRepositoryProvider),
nativeSyncApi: ref.watch(nativeSyncApiProvider),
cancellation: ref.watch(cancellationProvider),
),
);
@@ -51,5 +52,6 @@ final hashServiceProvider = Provider(
localAssetRepository: ref.watch(localAssetRepository),
nativeSyncApi: ref.watch(nativeSyncApiProvider),
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
cancellation: ref.watch(cancellationProvider),
),
);
@@ -6,7 +6,6 @@ import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
@@ -15,9 +14,6 @@ import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:share_plus/share_plus.dart';
@@ -26,8 +22,6 @@ final assetMediaRepositoryProvider = Provider((ref) => AssetMediaRepository(ref.
class AssetMediaRepository {
final NativeSyncApi _nativeSyncApi;
static final Logger _log = Logger("AssetMediaRepository");
static const int _localPreviewMaxDimension = 1440;
static const int _localPreviewQuality = 90;
const AssetMediaRepository(this._nativeSyncApi);
@@ -111,210 +105,9 @@ class AssetMediaRepository {
);
}
String? _getLocalId(BaseAsset asset) {
if (asset is LocalAsset) {
return asset.id;
}
if (asset is RemoteAsset) {
return asset.localId;
}
return null;
}
String? _getRemoteId(BaseAsset asset) {
if (asset is RemoteAsset) {
return asset.id;
}
return asset.remoteId;
}
String _sanitizeFilename(String filename) {
return filename.replaceAll(RegExp(r'[\\/]'), '_');
}
String _getPreviewFilename(BaseAsset asset) {
final sanitizedFilename = _sanitizeFilename(asset.name);
final baseName = p.basenameWithoutExtension(sanitizedFilename);
final fallbackName = asset.remoteId ?? asset.localId ?? 'asset';
return '${baseName.isEmpty ? fallbackName : baseName}-preview.jpg';
}
ThumbnailSize _getLocalPreviewSize(BaseAsset asset) {
final width = asset.width;
final height = asset.height;
if (width == null || height == null || width <= 0 || height <= 0) {
return const ThumbnailSize.square(_localPreviewMaxDimension);
}
if (width >= height) {
final scaledHeight = (height * _localPreviewMaxDimension / width).round();
return ThumbnailSize(_localPreviewMaxDimension, scaledHeight < 1 ? 1 : scaledHeight);
}
final scaledWidth = (width * _localPreviewMaxDimension / height).round();
return ThumbnailSize(scaledWidth < 1 ? 1 : scaledWidth, _localPreviewMaxDimension);
}
Future<({File file, bool cleanup})?> _getLocalOriginalShareFile(BaseAsset asset, String localId) async {
final file = await AssetEntity(
id: localId,
width: asset.width ?? 1,
height: asset.height ?? 1,
typeInt: asset.type.index,
).originFile;
if (file == null) {
_log.warning("Local original file not found for sharing: $asset");
return null;
}
return (file: file, cleanup: CurrentPlatform.isIOS);
}
Future<({File file, bool cleanup})?> _getLocalPreviewShareFile(BaseAsset asset, String localId) async {
final entity = AssetEntity(
id: localId,
width: asset.width ?? 1,
height: asset.height ?? 1,
typeInt: asset.type.index,
);
final data = await entity.thumbnailDataWithSize(
_getLocalPreviewSize(asset),
format: ThumbnailFormat.jpeg,
quality: _localPreviewQuality,
);
if (data == null) {
_log.warning("Local preview file not found for sharing: $asset");
return null;
}
final tempDirectory = await getTemporaryDirectory();
final file = File(
p.join(tempDirectory.path, 'immich-share-${DateTime.now().microsecondsSinceEpoch}-${_getPreviewFilename(asset)}'),
);
await file.writeAsBytes(data, flush: true);
return (file: file, cleanup: true);
}
Future<({File file, bool cleanup})?> _downloadRemoteShareFile({
required String taskId,
required String url,
required String filename,
Completer<void>? cancelCompleter,
required void Function(double progress) onProgress,
}) async {
final task = DownloadTask(
taskId: taskId,
url: url,
headers: ApiService.getRequestHeaders(),
filename: filename,
baseDirectory: BaseDirectory.temporary,
group: kShareDownloadGroup,
updates: Updates.statusAndProgress,
);
final downloader = FileDownloader();
final statusUpdate = await downloader.download(
task,
onProgress: (value) {
if (cancelCompleter != null && cancelCompleter.isCompleted) {
unawaited(downloader.cancelTaskWithId(taskId));
return;
}
onProgress(value);
},
);
if (cancelCompleter != null && cancelCompleter.isCompleted) {
return null;
}
if (statusUpdate.status == TaskStatus.complete) {
return (file: File(await task.filePath()), cleanup: true);
}
_log.severe("Download for $filename failed with status ${statusUpdate.status}", statusUpdate.exception);
return null;
}
Future<({File file, bool cleanup})?> _getRemoteOriginalShareFile(
BaseAsset asset,
String remoteId, {
Completer<void>? cancelCompleter,
required void Function(double progress) onProgress,
}) {
return _downloadRemoteShareFile(
taskId: 'share-original-$remoteId-${DateTime.now().microsecondsSinceEpoch}',
url: getOriginalUrlForRemoteId(remoteId, edited: asset.isEdited),
filename: _sanitizeFilename(asset.name),
cancelCompleter: cancelCompleter,
onProgress: onProgress,
);
}
Future<({File file, bool cleanup})?> _getRemotePreviewShareFile(
BaseAsset asset,
String remoteId, {
Completer<void>? cancelCompleter,
required void Function(double progress) onProgress,
}) {
return _downloadRemoteShareFile(
taskId: 'share-preview-$remoteId-${DateTime.now().microsecondsSinceEpoch}',
url: getThumbnailUrlForRemoteId(remoteId, type: AssetMediaSize.preview, edited: asset.isEdited),
filename: _getPreviewFilename(asset),
cancelCompleter: cancelCompleter,
onProgress: onProgress,
);
}
Future<({File file, bool cleanup})?> _getOriginalShareFile(
BaseAsset asset, {
Completer<void>? cancelCompleter,
required void Function(double progress) onProgress,
}) {
final localId = _getLocalId(asset);
if (localId != null && !asset.isEdited) {
return _getLocalOriginalShareFile(asset, localId);
}
final remoteId = _getRemoteId(asset);
if (remoteId == null) {
_log.warning("Asset has no remote ID for sharing: $asset");
return Future.value(null);
}
return _getRemoteOriginalShareFile(asset, remoteId, cancelCompleter: cancelCompleter, onProgress: onProgress);
}
Future<({File file, bool cleanup})?> _getPreviewShareFile(
BaseAsset asset, {
Completer<void>? cancelCompleter,
required void Function(double progress) onProgress,
}) async {
final remoteId = _getRemoteId(asset);
if (remoteId != null) {
final remotePreview = await _getRemotePreviewShareFile(
asset,
remoteId,
cancelCompleter: cancelCompleter,
onProgress: onProgress,
);
if (remotePreview != null || asset.isEdited) {
return remotePreview;
}
}
final localId = _getLocalId(asset);
if (localId != null) {
return _getLocalPreviewShareFile(asset, localId);
}
_log.warning("Asset has no local or remote ID for preview sharing: $asset");
return null;
}
Future<int> shareAssets(
List<BaseAsset> assets,
BuildContext context, {
ShareAssetFileType fileType = ShareAssetFileType.original,
Completer<void>? cancelCompleter,
void Function(double progress)? onAssetDownloadProgress,
}) async {
@@ -336,43 +129,75 @@ class AssetMediaRepository {
updateProgress();
for (final asset in assets) {
for (var asset in assets) {
if (cancelCompleter != null && cancelCompleter.isCompleted) {
// if cancelled, delete any temp files created so far
await _cleanupTempFiles(tempFiles);
return 0;
}
final shareFile = switch (fileType) {
ShareAssetFileType.original => await _getOriginalShareFile(
asset,
cancelCompleter: cancelCompleter,
onProgress: updateProgress,
),
ShareAssetFileType.preview => await _getPreviewShareFile(
asset,
cancelCompleter: cancelCompleter,
onProgress: updateProgress,
),
};
if (cancelCompleter != null && cancelCompleter.isCompleted) {
await _cleanupTempFiles(tempFiles);
return 0;
}
if (shareFile == null) {
final localId = (asset is LocalAsset)
? asset.id
: asset is RemoteAsset
? asset.localId
: null;
if (localId != null && !asset.isEdited) {
File? f = await AssetEntity(id: localId, width: 1, height: 1, typeInt: 0).originFile;
downloadedXFiles.add(XFile(f!.path));
processedAssets++;
updateProgress();
continue;
}
if (CurrentPlatform.isIOS) {
tempFiles.add(f);
}
} else {
final remoteId = (asset is RemoteAsset) ? asset.id : asset.remoteId;
if (remoteId == null) {
_log.warning("Asset has no remote ID for sharing: $asset");
processedAssets++;
updateProgress();
continue;
}
downloadedXFiles.add(XFile(shareFile.file.path));
if (shareFile.cleanup) {
tempFiles.add(shareFile.file);
final taskId = 'share-$remoteId-${DateTime.now().microsecondsSinceEpoch}';
final sanitizedFilename = asset.name.replaceAll(RegExp(r'[\\/]'), '_');
final task = DownloadTask(
taskId: taskId,
url: getOriginalUrlForRemoteId(remoteId, edited: asset.isEdited),
headers: ApiService.getRequestHeaders(),
filename: sanitizedFilename,
baseDirectory: BaseDirectory.temporary,
group: kShareDownloadGroup,
updates: Updates.statusAndProgress,
);
final statusUpdate = await FileDownloader().download(
task,
onProgress: (value) {
if (cancelCompleter != null && cancelCompleter.isCompleted) {
unawaited(FileDownloader().cancelTaskWithId(taskId));
return;
}
updateProgress(value);
},
);
if (cancelCompleter != null && cancelCompleter.isCompleted) {
await _cleanupTempFiles(tempFiles);
return 0;
}
if (statusUpdate.status == TaskStatus.complete) {
final filePath = await task.filePath();
final file = File(filePath);
tempFiles.add(file);
downloadedXFiles.add(XFile(filePath));
processedAssets++;
updateProgress();
continue;
}
_log.severe("Download for ${asset.name} failed with status ${statusUpdate.status}", statusUpdate.exception);
processedAssets++;
updateProgress();
}
processedAssets++;
updateProgress();
}
if (downloadedXFiles.isEmpty) {
+8 -10
View File
@@ -1,40 +1,38 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/config/app_config.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
final authRepositoryProvider = Provider<AuthRepository>(
(ref) => AuthRepository(ref.watch(driftProvider), ref.watch(settingsProvider)),
(ref) => AuthRepository(ref.watch(driftProvider), ref.watch(appConfigProvider)),
);
class AuthRepository {
final Drift _drift;
final SettingsRepository _settings;
final AppConfig _config;
const AuthRepository(this._drift, this._settings);
const AuthRepository(this._drift, this._config);
Future<void> clearLocalData() async {
await SyncStreamRepository(_drift).reset();
}
bool getEndpointSwitchingFeature() {
return _settings.appConfig.network.autoEndpointSwitching;
return _config.network.autoEndpointSwitching;
}
String? getPreferredWifiName() {
return _settings.appConfig.network.preferredWifiName;
return _config.network.preferredWifiName;
}
String? getLocalEndpoint() {
return _settings.appConfig.network.localEndpoint;
return _config.network.localEndpoint;
}
List<AuxilaryEndpoint> getExternalEndpointList() {
return _settings.appConfig.network.externalEndpointList
.map((url) => AuxilaryEndpoint(url: url, status: .valid))
.toList();
return _config.network.externalEndpointList.map((url) => AuxilaryEndpoint(url: url, status: .valid)).toList();
}
}
@@ -41,8 +41,14 @@ class DriftAlbumApiRepository extends ApiRepository {
return (removed: removed, failed: failed);
}
Future<({List<String> added, List<String> failed})> addAssets(String albumId, Iterable<String> assetIds) async {
final response = await checkNull(_api.addAssetsToAlbum(albumId, BulkIdsDto(ids: assetIds.toList())));
Future<({List<String> added, List<String> failed})> addAssets(
String albumId,
Iterable<String> assetIds, {
Future<void>? abortTrigger,
}) async {
final response = await checkNull(
_api.addAssetsToAlbum(albumId, BulkIdsDto(ids: assetIds.toList()), abortTrigger: abortTrigger),
);
final List<String> added = [], failed = [];
for (final dto in response) {
if (dto.success) {
-2
View File
@@ -272,14 +272,12 @@ class ActionService {
Future<int> shareAssets(
List<BaseAsset> assets,
BuildContext context, {
ShareAssetFileType fileType = ShareAssetFileType.original,
Completer<void>? cancelCompleter,
void Function(double progress)? onAssetDownloadProgress,
}) {
return _assetMediaRepository.shareAssets(
assets,
context,
fileType: fileType,
cancelCompleter: cancelCompleter,
onAssetDownloadProgress: onAssetDownloadProgress,
);
+21 -45
View File
@@ -8,10 +8,9 @@ import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/utils/bootstrap.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/wm_executor.dart';
import 'package:logging/logging.dart';
import 'package:worker_manager/worker_manager.dart';
import 'package:worker_manager/worker_manager.dart' show Cancelable;
class InvalidIsolateUsageException implements Exception {
const InvalidIsolateUsageException();
@@ -30,50 +29,27 @@ Cancelable<T?> runInIsolateGentle<T>({
throw const InvalidIsolateUsageException();
}
return workerManagerPatch.executeGentle((cancelledChecker) async {
T? result;
await runZonedGuarded(
() async {
BackgroundIsolateBinaryMessenger.ensureInitialized(token);
DartPluginRegistrant.ensureInitialized();
return workerManagerPatch.executeGentle((onCancel) async {
BackgroundIsolateBinaryMessenger.ensureInitialized(token);
DartPluginRegistrant.ensureInitialized();
final (drift, logDb) = await Bootstrap.initDomain(shouldBufferLogs: false, listenStoreUpdates: false);
final ref = ProviderContainer(
overrides: [
cancellationProvider.overrideWithValue(cancelledChecker),
driftProvider.overrideWith(driftOverride(drift)),
],
);
Logger log = Logger("IsolateLogger");
try {
result = await computation(ref);
} on CanceledError {
log.warning("Computation cancelled ${debugLabel == null ? '' : ' for $debugLabel'}");
} catch (error, stack) {
log.severe("Error in runInIsolateGentle ${debugLabel == null ? '' : ' for $debugLabel'}", error, stack);
} finally {
try {
ref.dispose();
await Store.dispose();
await LogService.I.dispose();
await logDb.close();
await drift.close();
} catch (error, stack) {
dPrint(() => "Error closing resources in isolate: $error, $stack");
} finally {
ref.dispose();
// Delay to ensure all resources are released
await Future.delayed(const Duration(seconds: 2));
}
}
},
(error, stack) {
dPrint(() => "Error in isolate $debugLabel zone: $error, $stack");
},
final log = Logger("IsolateLogger");
final (drift, logDb) = await Bootstrap.initDomain(shouldBufferLogs: false, listenStoreUpdates: false);
final ref = ProviderContainer(
overrides: [cancellationProvider.overrideWithValue(onCancel), driftProvider.overrideWith(driftOverride(drift))],
);
return result;
try {
return await computation(ref);
} catch (error, stack) {
log.severe("Error in runInIsolateGentle${debugLabel == null ? '' : ' for $debugLabel'}", error, stack);
return null;
} finally {
ref.dispose();
await Store.dispose();
await LogService.I.dispose();
await logDb.close();
await drift.close();
}
});
}
+163
View File
@@ -0,0 +1,163 @@
// Forked from worker_manager's `WorkerImpl` (src/worker/worker_io.dart): a
// `CancelRequest` completes the computation's [Completer] (so it can await
// cancellation and unwind) instead of flipping a polled flag, and [shutdown]
// lets the isolate drain and exit on its own rather than force-killing it. Only
// the gentle-with-cancellation path immich uses is kept.
//
// ignore_for_file: implementation_imports
import 'dart:async';
import 'dart:isolate';
import 'package:worker_manager/src/scheduling/task.dart';
import 'package:worker_manager/src/worker/cancel_request.dart';
import 'package:worker_manager/src/worker/result.dart';
/// A worker computation that receives a [Completer] which completes on
/// cancellation: await its future to react promptly, or read `isCompleted`.
typedef GentleExecution<R> = FutureOr<R> Function(Completer<void> onCancel);
class _Shutdown {
const _Shutdown();
}
class IsolateWorker {
IsolateWorker();
Isolate? _isolate;
RawReceivePort? _receivePort;
SendPort? _sendPort;
Completer<void>? _sendPortReceived;
Completer? _result;
String? taskId;
bool get initialized => _sendPortReceived?.isCompleted ?? false;
bool get initializing {
final sendPortReceived = _sendPortReceived;
return sendPortReceived != null && !sendPortReceived.isCompleted;
}
Future<void> initialize() async {
final sendPortReceived = _sendPortReceived = Completer<void>();
final receivePort = _receivePort = RawReceivePort();
receivePort.handler = (Object message) {
if (message is SendPort) {
_sendPort = message;
sendPortReceived.complete();
} else if (message is ResultSuccess) {
_result?.complete(message.value);
_afterTask();
} else if (message is ResultError) {
_result?.completeError(message.error, message.stackTrace);
_afterTask();
}
};
_isolate = await Isolate.spawn(_isolateEntry, receivePort.sendPort, errorsAreFatal: false);
await sendPortReceived.future;
}
Future<R> work<R>(Task<R> task) async {
taskId = task.id;
final result = _result = Completer();
_sendPort!.send(task.execution);
return await (result.future as Future<R>);
}
/// Cancels the current task without retiring the worker.
void cancelGentle() => _sendPort?.send(CancelRequest());
/// Cancels any in-flight task and awaits the isolate exiting on its own — no
/// force-kill, so `finally` blocks and native cleanup always run.
///
/// Detaches the slot up front so a concurrent [initialize] can revive it
/// without colliding (revival installs fresh ports while this drains the ones
/// it captured locally). A revived worker is always idle, so the still-live
/// receive-port handler can't misroute a result.
Future<void> shutdown() async {
final sendPortReceived = _sendPortReceived;
if (sendPortReceived != null && !sendPortReceived.isCompleted) {
await sendPortReceived.future;
}
final isolate = _isolate;
final receivePort = _receivePort;
final sendPort = _sendPort;
if (isolate == null || receivePort == null || sendPort == null) {
return;
}
_isolate = null;
_sendPort = null;
_sendPortReceived = null;
// Not _result: an in-flight task still delivers it before exiting; nulling
// here would drop that and hang work()'s caller.
final exited = Completer<void>();
final exitPort = RawReceivePort();
exitPort.handler = (_) {
if (!exited.isCompleted) {
exited.complete();
}
exitPort.close();
};
isolate.addOnExitListener(exitPort.sendPort);
sendPort.send(const _Shutdown());
await exited.future;
receivePort.close();
}
void _afterTask() {
taskId = null;
_result = null;
}
static void _isolateEntry(SendPort sendPort) {
final receivePort = RawReceivePort();
sendPort.send(receivePort.sendPort);
// One task at a time, so a single completer suffices; null between tasks.
Completer<void>? onCancel;
void cancel() {
if (onCancel?.isCompleted == false) {
onCancel!.complete();
}
}
var shuttingDown = false;
var running = false;
receivePort.handler = (message) async {
if (message is _Shutdown) {
shuttingDown = true;
cancel();
if (!running) {
Isolate.exit();
}
return;
}
if (message is CancelRequest) {
cancel();
return;
}
final execution = message as GentleExecution;
onCancel = Completer<void>();
running = true;
Result result;
try {
result = ResultSuccess(await execution(onCancel!));
} catch (error, stackTrace) {
result = ResultError(error, stackTrace);
} finally {
onCancel = null;
running = false;
}
if (shuttingDown) {
// An isolate that has used platform channels can't exit on its own (Flutter's BackgroundIsolateBinaryMessenger
// opens an undisposable port), so closing our ports isn't enough. Isolate.exit delivers the result as its final
// message and terminates. It's abrupt (skips pending finally/microtasks) but safe here: the computation and its
// `finally` are already done and there's no await before this, so nothing pending is skipped.
Isolate.exit(sendPort, result);
}
sendPort.send(result);
};
}
}
+20 -52
View File
@@ -1,42 +1,36 @@
enum SemVerType { major, minor, patch, prerelease }
enum SemVerType { major, minor, patch }
class SemVer {
final int major;
final int minor;
final int patch;
final int? prerelease;
const SemVer({required this.major, required this.minor, required this.patch, this.prerelease});
const SemVer({required this.major, required this.minor, required this.patch});
@override
String toString() {
return '$major.$minor.$patch${prerelease == null ? '' : '-rc.$prerelease'}';
return '$major.$minor.$patch';
}
SemVer copyWith({int? major, int? minor, int? patch, int? prerelease}) {
return SemVer(
major: major ?? this.major,
minor: minor ?? this.minor,
patch: patch ?? this.patch,
prerelease: prerelease ?? this.prerelease,
);
SemVer copyWith({int? major, int? minor, int? patch}) {
return SemVer(major: major ?? this.major, minor: minor ?? this.minor, patch: patch ?? this.patch);
}
static final _pattern = RegExp(r'^v?(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?(?:[-+].*)?$', caseSensitive: false);
factory SemVer.fromString(String version) {
final match = _pattern.firstMatch(version);
if (match == null) {
if (version.toLowerCase().startsWith("v")) {
version = version.substring(1);
}
final parts = version.split("-")[0].split('.');
if (parts.length != 3) {
throw FormatException('Invalid semantic version string: $version');
}
final prerelease = match.group(4);
return SemVer(
major: int.parse(match.group(1)!),
minor: int.parse(match.group(2)!),
patch: int.parse(match.group(3)!),
prerelease: prerelease == null ? null : int.parse(prerelease),
);
try {
return SemVer(major: int.parse(parts[0]), minor: int.parse(parts[1]), patch: int.parse(parts[2]));
} catch (e) {
throw FormatException('Invalid semantic version string: $version');
}
}
bool operator >(SemVer other) {
@@ -46,10 +40,7 @@ class SemVer {
if (minor != other.minor) {
return minor > other.minor;
}
if (patch != other.patch) {
return patch > other.patch;
}
return _comparePrerelease(other) > 0;
return patch > other.patch;
}
bool operator <(SemVer other) {
@@ -59,23 +50,7 @@ class SemVer {
if (minor != other.minor) {
return minor < other.minor;
}
if (patch != other.patch) {
return patch < other.patch;
}
return _comparePrerelease(other) < 0;
}
int _comparePrerelease(SemVer other) {
if (prerelease == other.prerelease) {
return 0;
}
if (prerelease == null) {
return 1;
}
if (other.prerelease == null) {
return -1;
}
return prerelease!.compareTo(other.prerelease!);
return patch < other.patch;
}
bool operator >=(SemVer other) {
@@ -92,11 +67,7 @@ class SemVer {
return true;
}
return other is SemVer &&
other.major == major &&
other.minor == minor &&
other.patch == patch &&
other.prerelease == prerelease;
return other is SemVer && other.major == major && other.minor == minor && other.patch == patch;
}
SemVerType? differenceType(SemVer other) {
@@ -109,13 +80,10 @@ class SemVer {
if (patch != other.patch) {
return SemVerType.patch;
}
if (prerelease != other.prerelease) {
return SemVerType.prerelease;
}
return null;
}
@override
int get hashCode => major.hashCode ^ minor.hashCode ^ patch.hashCode ^ prerelease.hashCode;
int get hashCode => major.hashCode ^ minor.hashCode ^ patch.hashCode;
}
@@ -50,7 +50,9 @@ class AppBarServerInfo extends HookConsumerWidget {
divider,
_ServerInfoItem(
label: "server_version".tr(),
text: serverInfoState.serverVersion.major > 0 ? "${serverInfoState.serverVersion}" : "--",
text: serverInfoState.serverVersion.major > 0
? "${serverInfoState.serverVersion.major}.${serverInfoState.serverVersion.minor}.${serverInfoState.serverVersion.patch}"
: "--",
),
divider,
_ServerInfoItem(label: "server_info_box_server_url".tr(), text: getServerUrl() ?? '--', tooltip: true),
@@ -58,7 +60,9 @@ class AppBarServerInfo extends HookConsumerWidget {
divider,
_ServerInfoItem(
label: "latest_version".tr(),
text: serverInfoState.latestVersion!.major > 0 ? "${serverInfoState.latestVersion!}" : "--",
text: serverInfoState.latestVersion!.major > 0
? "${serverInfoState.latestVersion!.major}.${serverInfoState.latestVersion!.minor}.${serverInfoState.latestVersion!.patch}"
: "--",
tooltip: true,
icon: serverInfoState.versionStatus == VersionStatus.serverOutOfDate
? const Icon(Icons.info, color: Color.fromARGB(255, 243, 188, 106), size: 12)
+31 -109
View File
@@ -6,8 +6,8 @@ import 'dart:math';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:immich_mobile/utils/isolate_worker.dart';
import 'package:worker_manager/src/number_of_processors/processors_io.dart';
import 'package:worker_manager/src/worker/worker.dart';
import 'package:worker_manager/worker_manager.dart';
final workerManagerPatch = _Executor();
@@ -16,6 +16,13 @@ final workerManagerPatch = _Executor();
const _minId = -9007199254740992;
const _maxId = 9007199254740992;
class _GentleTask<R> extends Task<R> implements Gentle {
@override
final GentleExecution<R> execution;
_GentleTask({required super.id, required super.completer, required super.workPriority, required this.execution});
}
class Mixinable<T> {
late final itSelf = this as T;
}
@@ -51,13 +58,13 @@ mixin _ExecutorLogger on Mixinable<_Executor> {
class _Executor extends Mixinable<_Executor> with _ExecutorLogger {
final _queue = PriorityQueue<Task>();
final _pool = <Worker>[];
final _pool = <IsolateWorker>[];
var _nextTaskId = _minId;
var _dynamicSpawning = false;
var _isolatesCount = numberOfProcessors;
@visibleForTesting
UnmodifiableListView<Worker> get pool => UnmodifiableListView(_pool);
UnmodifiableListView<IsolateWorker> get pool => UnmodifiableListView(_pool);
@override
Future<void> init({int? isolatesCount, bool? dynamicSpawning}) async {
@@ -80,117 +87,37 @@ class _Executor extends Mixinable<_Executor> with _ExecutorLogger {
@override
Future<void> dispose() async {
_queue.clear();
for (final worker in _pool) {
if (worker.initialized || worker.initializing) {
worker.kill();
}
}
final shutdown = _pool.map((worker) => worker.shutdown()).toList(growable: false);
_pool.clear();
await Future.wait(shutdown);
super.dispose();
}
Cancelable<R> execute<R>(Execute<R> execution, {WorkPriority priority = WorkPriority.immediately}) {
return _createCancelable<R>(execution: execution, priority: priority);
}
Cancelable<R> executeNow<R>(ExecuteGentle<R> execution) {
final task = TaskGentle<R>(
id: "",
workPriority: WorkPriority.immediately,
execution: execution,
completer: Completer<R>(),
);
Future<void> run() async {
try {
final result = await execution(() => task.canceled);
task.complete(result, null, null);
} catch (error, st) {
task.complete(null, error, st);
}
}
run();
return Cancelable(completer: task.completer, onCancel: () => _cancel(task));
}
Cancelable<R> executeWithPort<R, T>(
ExecuteWithPort<R> execution, {
WorkPriority priority = WorkPriority.immediately,
required void Function(T value) onMessage,
}) {
return _createCancelable<R>(
execution: execution,
priority: priority,
onMessage: (message) => onMessage(message as T),
);
}
Cancelable<R> executeGentle<R>(ExecuteGentle<R> execution, {WorkPriority priority = WorkPriority.immediately}) {
return _createCancelable<R>(execution: execution, priority: priority);
}
Cancelable<R> executeGentleWithPort<R, T>(
ExecuteGentleWithPort<R> execution, {
WorkPriority priority = WorkPriority.immediately,
required void Function(T value) onMessage,
}) {
return _createCancelable<R>(
execution: execution,
priority: priority,
onMessage: (message) => onMessage(message as T),
);
}
void _createWorkers() {
for (var i = 0; i < _isolatesCount; i++) {
_pool.add(Worker());
}
}
Future<void> _initializeWorkers() async {
await Future.wait(_pool.map((e) => e.initialize()));
}
Cancelable<R> _createCancelable<R>({
required Function execution,
WorkPriority priority = WorkPriority.immediately,
void Function(Object value)? onMessage,
}) {
/// Runs [execution] on a worker isolate; its [Completer] completes when the
/// returned [Cancelable] is cancelled.
Cancelable<R> executeGentle<R>(GentleExecution<R> execution, {WorkPriority priority = WorkPriority.immediately}) {
if (_nextTaskId + 1 == _maxId) {
_nextTaskId = _minId;
}
final id = _nextTaskId.toString();
_nextTaskId++;
late final Task<R> task;
final completer = Completer<R>();
if (execution is ExecuteWithPort<R>) {
task = TaskWithPort<R>(
id: id,
workPriority: priority,
execution: execution,
completer: completer,
onMessage: onMessage!,
);
} else if (execution is ExecuteGentle<R>) {
task = TaskGentle<R>(id: id, workPriority: priority, execution: execution, completer: completer);
} else if (execution is ExecuteGentleWithPort<R>) {
task = TaskGentleWithPort<R>(
id: id,
workPriority: priority,
execution: execution,
completer: completer,
onMessage: onMessage!,
);
} else if (execution is Execute<R>) {
task = TaskRegular<R>(id: id, workPriority: priority, execution: execution, completer: completer);
}
final task = _GentleTask<R>(id: id, workPriority: priority, execution: execution, completer: Completer<R>());
_queue.add(task);
_schedule();
logTaskAdded(task.id);
return Cancelable(completer: task.completer, onCancel: () => _cancel(task));
}
void _createWorkers() {
for (var i = 0; i < _isolatesCount; i++) {
_pool.add(IsolateWorker());
}
}
Future<void> _initializeWorkers() async {
await Future.wait(_pool.map((e) => e.initialize()));
}
Future<void> _ensureWorkersInitialized() async {
if (_pool.isEmpty) {
_createWorkers();
@@ -240,7 +167,9 @@ class _Executor extends Mixinable<_Executor> with _ExecutorLogger {
)
.whenComplete(() {
if (_dynamicSpawning && _queue.isEmpty) {
availableWorker.kill();
// Retire the idle worker; shutdown() nulls its fields so the husk
// stays pooled and is revived by initialize() if work arrives.
unawaited(availableWorker.shutdown());
}
_schedule();
});
@@ -250,15 +179,8 @@ class _Executor extends Mixinable<_Executor> with _ExecutorLogger {
void _cancel(Task task) {
task.cancel();
_queue.remove(task);
final targetWorker = _pool.firstWhereOrNull((worker) => worker.taskId == task.id);
if (task is Gentle) {
targetWorker?.cancelGentle();
} else {
targetWorker?.kill();
if (!_dynamicSpawning) {
targetWorker?.initialize();
}
}
// All tasks are gentle: signal cancellation; the worker unwinds on its own.
_pool.firstWhereOrNull((worker) => worker.taskId == task.id)?.cancelGentle();
super._cancel(task);
}
}
-8
View File
@@ -103,16 +103,12 @@ Class | Method | HTTP request | Description
*AssetsApi* | [**deleteBulkAssetMetadata**](doc//AssetsApi.md#deletebulkassetmetadata) | **DELETE** /assets/metadata | Delete asset metadata
*AssetsApi* | [**downloadAsset**](doc//AssetsApi.md#downloadasset) | **GET** /assets/{id}/original | Download original asset
*AssetsApi* | [**editAsset**](doc//AssetsApi.md#editasset) | **PUT** /assets/{id}/edits | Apply edits to an existing asset
*AssetsApi* | [**endSession**](doc//AssetsApi.md#endsession) | **DELETE** /assets/{id}/video/stream/{sessionId} | End HLS streaming session
*AssetsApi* | [**getAssetEdits**](doc//AssetsApi.md#getassetedits) | **GET** /assets/{id}/edits | Retrieve edits for an existing asset
*AssetsApi* | [**getAssetInfo**](doc//AssetsApi.md#getassetinfo) | **GET** /assets/{id} | Retrieve an asset
*AssetsApi* | [**getAssetMetadata**](doc//AssetsApi.md#getassetmetadata) | **GET** /assets/{id}/metadata | Get asset metadata
*AssetsApi* | [**getAssetMetadataByKey**](doc//AssetsApi.md#getassetmetadatabykey) | **GET** /assets/{id}/metadata/{key} | Retrieve asset metadata by key
*AssetsApi* | [**getAssetOcr**](doc//AssetsApi.md#getassetocr) | **GET** /assets/{id}/ocr | Retrieve asset OCR data
*AssetsApi* | [**getAssetStatistics**](doc//AssetsApi.md#getassetstatistics) | **GET** /assets/statistics | Get asset statistics
*AssetsApi* | [**getMainPlaylist**](doc//AssetsApi.md#getmainplaylist) | **GET** /assets/{id}/video/stream/main.m3u8 | Get HLS main playlist
*AssetsApi* | [**getMediaPlaylist**](doc//AssetsApi.md#getmediaplaylist) | **GET** /assets/{id}/video/stream/{sessionId}/{variantIndex}/playlist.m3u8 | Get HLS media playlist
*AssetsApi* | [**getSegment**](doc//AssetsApi.md#getsegment) | **GET** /assets/{id}/video/stream/{sessionId}/{variantIndex}/{filename} | Get HLS segment or init file
*AssetsApi* | [**playAssetVideo**](doc//AssetsApi.md#playassetvideo) | **GET** /assets/{id}/video/playback | Play asset video
*AssetsApi* | [**removeAssetEdits**](doc//AssetsApi.md#removeassetedits) | **DELETE** /assets/{id}/edits | Remove edits from an existing asset
*AssetsApi* | [**runAssetJobs**](doc//AssetsApi.md#runassetjobs) | **POST** /assets/jobs | Run an asset job
@@ -517,9 +513,6 @@ Class | Method | HTTP request | Description
- [RatingsUpdate](doc//RatingsUpdate.md)
- [ReactionLevel](doc//ReactionLevel.md)
- [ReactionType](doc//ReactionType.md)
- [ReleaseChannel](doc//ReleaseChannel.md)
- [ReleaseEventV1](doc//ReleaseEventV1.md)
- [ReleaseType](doc//ReleaseType.md)
- [ReverseGeocodingStateResponseDto](doc//ReverseGeocodingStateResponseDto.md)
- [RotateParameters](doc//RotateParameters.md)
- [SearchAlbumResponseDto](doc//SearchAlbumResponseDto.md)
@@ -604,7 +597,6 @@ Class | Method | HTTP request | Description
- [SystemConfigBackupsDto](doc//SystemConfigBackupsDto.md)
- [SystemConfigDto](doc//SystemConfigDto.md)
- [SystemConfigFFmpegDto](doc//SystemConfigFFmpegDto.md)
- [SystemConfigFFmpegRealtimeDto](doc//SystemConfigFFmpegRealtimeDto.md)
- [SystemConfigFacesDto](doc//SystemConfigFacesDto.md)
- [SystemConfigGeneratedFullsizeImageDto](doc//SystemConfigGeneratedFullsizeImageDto.md)
- [SystemConfigGeneratedImageDto](doc//SystemConfigGeneratedImageDto.md)
-4
View File
@@ -258,9 +258,6 @@ part 'model/ratings_response.dart';
part 'model/ratings_update.dart';
part 'model/reaction_level.dart';
part 'model/reaction_type.dart';
part 'model/release_channel.dart';
part 'model/release_event_v1.dart';
part 'model/release_type.dart';
part 'model/reverse_geocoding_state_response_dto.dart';
part 'model/rotate_parameters.dart';
part 'model/search_album_response_dto.dart';
@@ -345,7 +342,6 @@ part 'model/sync_user_v1.dart';
part 'model/system_config_backups_dto.dart';
part 'model/system_config_dto.dart';
part 'model/system_config_f_fmpeg_dto.dart';
part 'model/system_config_f_fmpeg_realtime_dto.dart';
part 'model/system_config_faces_dto.dart';
part 'model/system_config_generated_fullsize_image_dto.dart';
part 'model/system_config_generated_image_dto.dart';
-314
View File
@@ -423,76 +423,6 @@ class AssetsApi {
return null;
}
/// End HLS streaming session
///
/// Releases server resources for the streaming session.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [String] sessionId (required):
///
/// * [String] key:
///
/// * [String] slug:
Future<Response> endSessionWithHttpInfo(String id, String sessionId, { String? key, String? slug, Future<void>? abortTrigger, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/assets/{id}/video/stream/{sessionId}'
.replaceAll('{id}', id)
.replaceAll('{sessionId}', sessionId);
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (key != null) {
queryParams.addAll(_queryParams('', 'key', key));
}
if (slug != null) {
queryParams.addAll(_queryParams('', 'slug', slug));
}
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'DELETE',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
abortTrigger: abortTrigger,
);
}
/// End HLS streaming session
///
/// Releases server resources for the streaming session.
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [String] sessionId (required):
///
/// * [String] key:
///
/// * [String] slug:
Future<void> endSession(String id, String sessionId, { String? key, String? slug, Future<void>? abortTrigger, }) async {
final response = await endSessionWithHttpInfo(id, sessionId, key: key, slug: slug, abortTrigger: abortTrigger,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
}
/// Retrieve edits for an existing asset
///
/// Retrieve a series of edit actions (crop, rotate, mirror) associated with the specified asset.
@@ -892,250 +822,6 @@ class AssetsApi {
return null;
}
/// Get HLS main playlist
///
/// Returns an HLS main playlist with all available variants for the asset.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [String] key:
///
/// * [String] slug:
Future<Response> getMainPlaylistWithHttpInfo(String id, { String? key, String? slug, Future<void>? abortTrigger, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/assets/{id}/video/stream/main.m3u8'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (key != null) {
queryParams.addAll(_queryParams('', 'key', key));
}
if (slug != null) {
queryParams.addAll(_queryParams('', 'slug', slug));
}
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
abortTrigger: abortTrigger,
);
}
/// Get HLS main playlist
///
/// Returns an HLS main playlist with all available variants for the asset.
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [String] key:
///
/// * [String] slug:
Future<String?> getMainPlaylist(String id, { String? key, String? slug, Future<void>? abortTrigger, }) async {
final response = await getMainPlaylistWithHttpInfo(id, key: key, slug: slug, abortTrigger: abortTrigger,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'String',) as String;
}
return null;
}
/// Get HLS media playlist
///
/// Returns an HLS media playlist for one variant of the streaming session.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [String] sessionId (required):
///
/// * [int] variantIndex (required):
///
/// * [String] key:
///
/// * [String] slug:
Future<Response> getMediaPlaylistWithHttpInfo(String id, String sessionId, int variantIndex, { String? key, String? slug, Future<void>? abortTrigger, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/assets/{id}/video/stream/{sessionId}/{variantIndex}/playlist.m3u8'
.replaceAll('{id}', id)
.replaceAll('{sessionId}', sessionId)
.replaceAll('{variantIndex}', variantIndex.toString());
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (key != null) {
queryParams.addAll(_queryParams('', 'key', key));
}
if (slug != null) {
queryParams.addAll(_queryParams('', 'slug', slug));
}
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
abortTrigger: abortTrigger,
);
}
/// Get HLS media playlist
///
/// Returns an HLS media playlist for one variant of the streaming session.
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [String] sessionId (required):
///
/// * [int] variantIndex (required):
///
/// * [String] key:
///
/// * [String] slug:
Future<String?> getMediaPlaylist(String id, String sessionId, int variantIndex, { String? key, String? slug, Future<void>? abortTrigger, }) async {
final response = await getMediaPlaylistWithHttpInfo(id, sessionId, variantIndex, key: key, slug: slug, abortTrigger: abortTrigger,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'String',) as String;
}
return null;
}
/// Get HLS segment or init file
///
/// Streams an HLS init segment (init.mp4) or media segment (seg_N.m4s).
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [String] filename (required):
///
/// * [String] id (required):
///
/// * [String] sessionId (required):
///
/// * [int] variantIndex (required):
///
/// * [String] key:
///
/// * [String] slug:
Future<Response> getSegmentWithHttpInfo(String filename, String id, String sessionId, int variantIndex, { String? key, String? slug, Future<void>? abortTrigger, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/assets/{id}/video/stream/{sessionId}/{variantIndex}/{filename}'
.replaceAll('{filename}', filename)
.replaceAll('{id}', id)
.replaceAll('{sessionId}', sessionId)
.replaceAll('{variantIndex}', variantIndex.toString());
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (key != null) {
queryParams.addAll(_queryParams('', 'key', key));
}
if (slug != null) {
queryParams.addAll(_queryParams('', 'slug', slug));
}
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
abortTrigger: abortTrigger,
);
}
/// Get HLS segment or init file
///
/// Streams an HLS init segment (init.mp4) or media segment (seg_N.m4s).
///
/// Parameters:
///
/// * [String] filename (required):
///
/// * [String] id (required):
///
/// * [String] sessionId (required):
///
/// * [int] variantIndex (required):
///
/// * [String] key:
///
/// * [String] slug:
Future<MultipartFile?> getSegment(String filename, String id, String sessionId, int variantIndex, { String? key, String? slug, Future<void>? abortTrigger, }) async {
final response = await getSegmentWithHttpInfo(filename, id, sessionId, variantIndex, key: key, slug: slug, abortTrigger: abortTrigger,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MultipartFile',) as MultipartFile;
}
return null;
}
/// Play asset video
///
/// Streams the video file for the specified asset. This endpoint also supports byte range requests.
-8
View File
@@ -563,12 +563,6 @@ class ApiClient {
return ReactionLevelTypeTransformer().decode(value);
case 'ReactionType':
return ReactionTypeTypeTransformer().decode(value);
case 'ReleaseChannel':
return ReleaseChannelTypeTransformer().decode(value);
case 'ReleaseEventV1':
return ReleaseEventV1.fromJson(value);
case 'ReleaseType':
return ReleaseTypeTypeTransformer().decode(value);
case 'ReverseGeocodingStateResponseDto':
return ReverseGeocodingStateResponseDto.fromJson(value);
case 'RotateParameters':
@@ -737,8 +731,6 @@ class ApiClient {
return SystemConfigDto.fromJson(value);
case 'SystemConfigFFmpegDto':
return SystemConfigFFmpegDto.fromJson(value);
case 'SystemConfigFFmpegRealtimeDto':
return SystemConfigFFmpegRealtimeDto.fromJson(value);
case 'SystemConfigFacesDto':
return SystemConfigFacesDto.fromJson(value);
case 'SystemConfigGeneratedFullsizeImageDto':
-6
View File
@@ -157,12 +157,6 @@ String parameterToString(dynamic value) {
if (value is ReactionType) {
return ReactionTypeTypeTransformer().encode(value).toString();
}
if (value is ReleaseChannel) {
return ReleaseChannelTypeTransformer().encode(value).toString();
}
if (value is ReleaseType) {
return ReleaseTypeTypeTransformer().encode(value).toString();
}
if (value is SearchSuggestionType) {
return SearchSuggestionTypeTypeTransformer().encode(value).toString();
}
-3
View File
@@ -52,7 +52,6 @@ class JobName {
static const librarySyncFilesQueueAll = JobName._(r'LibrarySyncFilesQueueAll');
static const librarySyncFiles = JobName._(r'LibrarySyncFiles');
static const libraryScanQueueAll = JobName._(r'LibraryScanQueueAll');
static const hlsSessionCleanup = JobName._(r'HlsSessionCleanup');
static const memoryCleanup = JobName._(r'MemoryCleanup');
static const memoryGenerate = JobName._(r'MemoryGenerate');
static const notificationsCleanup = JobName._(r'NotificationsCleanup');
@@ -111,7 +110,6 @@ class JobName {
librarySyncFilesQueueAll,
librarySyncFiles,
libraryScanQueueAll,
hlsSessionCleanup,
memoryCleanup,
memoryGenerate,
notificationsCleanup,
@@ -205,7 +203,6 @@ class JobNameTypeTransformer {
case r'LibrarySyncFilesQueueAll': return JobName.librarySyncFilesQueueAll;
case r'LibrarySyncFiles': return JobName.librarySyncFiles;
case r'LibraryScanQueueAll': return JobName.libraryScanQueueAll;
case r'HlsSessionCleanup': return JobName.hlsSessionCleanup;
case r'MemoryCleanup': return JobName.memoryCleanup;
case r'MemoryGenerate': return JobName.memoryGenerate;
case r'NotificationsCleanup': return JobName.notificationsCleanup;
-85
View File
@@ -1,85 +0,0 @@
//
// 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;
/// Release channel
class ReleaseChannel {
/// Instantiate a new enum with the provided [value].
const ReleaseChannel._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const stable = ReleaseChannel._(r'stable');
static const releaseCandidate = ReleaseChannel._(r'releaseCandidate');
/// List of all possible values in this [enum][ReleaseChannel].
static const values = <ReleaseChannel>[
stable,
releaseCandidate,
];
static ReleaseChannel? fromJson(dynamic value) => ReleaseChannelTypeTransformer().decode(value);
static List<ReleaseChannel> listFromJson(dynamic json, {bool growable = false,}) {
final result = <ReleaseChannel>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = ReleaseChannel.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [ReleaseChannel] to String,
/// and [decode] dynamic data back to [ReleaseChannel].
class ReleaseChannelTypeTransformer {
factory ReleaseChannelTypeTransformer() => _instance ??= const ReleaseChannelTypeTransformer._();
const ReleaseChannelTypeTransformer._();
String encode(ReleaseChannel data) => data.value;
/// Decodes a [dynamic value][data] to a ReleaseChannel.
///
/// 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.
ReleaseChannel? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'stable': return ReleaseChannel.stable;
case r'releaseCandidate': return ReleaseChannel.releaseCandidate;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [ReleaseChannelTypeTransformer] instance.
static ReleaseChannelTypeTransformer? _instance;
}
-133
View File
@@ -1,133 +0,0 @@
//
// 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;
class ReleaseEventV1 {
/// Returns a new [ReleaseEventV1] instance.
ReleaseEventV1({
required this.checkedAt,
required this.isAvailable,
required this.releaseVersion,
required this.serverVersion,
required this.type,
});
/// When the server last checked for a latest version. As an ISO timestamp
String checkedAt;
/// Whether a new version is available
bool isAvailable;
ServerVersionResponseDto releaseVersion;
ServerVersionResponseDto serverVersion;
ReleaseType type;
@override
bool operator ==(Object other) => identical(this, other) || other is ReleaseEventV1 &&
other.checkedAt == checkedAt &&
other.isAvailable == isAvailable &&
other.releaseVersion == releaseVersion &&
other.serverVersion == serverVersion &&
other.type == type;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(checkedAt.hashCode) +
(isAvailable.hashCode) +
(releaseVersion.hashCode) +
(serverVersion.hashCode) +
(type.hashCode);
@override
String toString() => 'ReleaseEventV1[checkedAt=$checkedAt, isAvailable=$isAvailable, releaseVersion=$releaseVersion, serverVersion=$serverVersion, type=$type]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'checkedAt'] = this.checkedAt;
json[r'isAvailable'] = this.isAvailable;
json[r'releaseVersion'] = this.releaseVersion;
json[r'serverVersion'] = this.serverVersion;
json[r'type'] = this.type;
return json;
}
/// Returns a new [ReleaseEventV1] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static ReleaseEventV1? fromJson(dynamic value) {
upgradeDto(value, "ReleaseEventV1");
if (value is Map) {
final json = value.cast<String, dynamic>();
return ReleaseEventV1(
checkedAt: mapValueOfType<String>(json, r'checkedAt')!,
isAvailable: mapValueOfType<bool>(json, r'isAvailable')!,
releaseVersion: ServerVersionResponseDto.fromJson(json[r'releaseVersion'])!,
serverVersion: ServerVersionResponseDto.fromJson(json[r'serverVersion'])!,
type: ReleaseType.fromJson(json[r'type'])!,
);
}
return null;
}
static List<ReleaseEventV1> listFromJson(dynamic json, {bool growable = false,}) {
final result = <ReleaseEventV1>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = ReleaseEventV1.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, ReleaseEventV1> mapFromJson(dynamic json) {
final map = <String, ReleaseEventV1>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = ReleaseEventV1.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of ReleaseEventV1-objects as value to a dart map
static Map<String, List<ReleaseEventV1>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<ReleaseEventV1>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = ReleaseEventV1.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'checkedAt',
'isAvailable',
'releaseVersion',
'serverVersion',
'type',
};
}
-100
View File
@@ -1,100 +0,0 @@
//
// 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;
class ReleaseType {
/// Instantiate a new enum with the provided [value].
const ReleaseType._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const major = ReleaseType._(r'major');
static const premajor = ReleaseType._(r'premajor');
static const minor = ReleaseType._(r'minor');
static const preminor = ReleaseType._(r'preminor');
static const patch_ = ReleaseType._(r'patch');
static const prepatch = ReleaseType._(r'prepatch');
static const prerelease = ReleaseType._(r'prerelease');
/// List of all possible values in this [enum][ReleaseType].
static const values = <ReleaseType>[
major,
premajor,
minor,
preminor,
patch_,
prepatch,
prerelease,
];
static ReleaseType? fromJson(dynamic value) => ReleaseTypeTypeTransformer().decode(value);
static List<ReleaseType> listFromJson(dynamic json, {bool growable = false,}) {
final result = <ReleaseType>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = ReleaseType.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [ReleaseType] to String,
/// and [decode] dynamic data back to [ReleaseType].
class ReleaseTypeTypeTransformer {
factory ReleaseTypeTypeTransformer() => _instance ??= const ReleaseTypeTypeTransformer._();
const ReleaseTypeTypeTransformer._();
String encode(ReleaseType data) => data.value;
/// Decodes a [dynamic value][data] to a ReleaseType.
///
/// 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.
ReleaseType? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'major': return ReleaseType.major;
case r'premajor': return ReleaseType.premajor;
case r'minor': return ReleaseType.minor;
case r'preminor': return ReleaseType.preminor;
case r'patch': return ReleaseType.patch_;
case r'prepatch': return ReleaseType.prepatch;
case r'prerelease': return ReleaseType.prerelease;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [ReleaseTypeTypeTransformer] instance.
static ReleaseTypeTypeTransformer? _instance;
}
+1 -10
View File
@@ -23,7 +23,6 @@ class ServerFeaturesDto {
required this.oauthAutoLaunch,
required this.ocr,
required this.passwordLogin,
required this.realtimeTranscoding,
required this.reverseGeocoding,
required this.search,
required this.sidecar,
@@ -61,9 +60,6 @@ class ServerFeaturesDto {
/// Whether password login is enabled
bool passwordLogin;
/// Whether real-time transcoding is enabled
bool realtimeTranscoding;
/// Whether reverse geocoding is enabled
bool reverseGeocoding;
@@ -91,7 +87,6 @@ class ServerFeaturesDto {
other.oauthAutoLaunch == oauthAutoLaunch &&
other.ocr == ocr &&
other.passwordLogin == passwordLogin &&
other.realtimeTranscoding == realtimeTranscoding &&
other.reverseGeocoding == reverseGeocoding &&
other.search == search &&
other.sidecar == sidecar &&
@@ -111,7 +106,6 @@ class ServerFeaturesDto {
(oauthAutoLaunch.hashCode) +
(ocr.hashCode) +
(passwordLogin.hashCode) +
(realtimeTranscoding.hashCode) +
(reverseGeocoding.hashCode) +
(search.hashCode) +
(sidecar.hashCode) +
@@ -119,7 +113,7 @@ class ServerFeaturesDto {
(trash.hashCode);
@override
String toString() => 'ServerFeaturesDto[configFile=$configFile, duplicateDetection=$duplicateDetection, email=$email, facialRecognition=$facialRecognition, importFaces=$importFaces, map=$map, oauth=$oauth, oauthAutoLaunch=$oauthAutoLaunch, ocr=$ocr, passwordLogin=$passwordLogin, realtimeTranscoding=$realtimeTranscoding, reverseGeocoding=$reverseGeocoding, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, trash=$trash]';
String toString() => 'ServerFeaturesDto[configFile=$configFile, duplicateDetection=$duplicateDetection, email=$email, facialRecognition=$facialRecognition, importFaces=$importFaces, map=$map, oauth=$oauth, oauthAutoLaunch=$oauthAutoLaunch, ocr=$ocr, passwordLogin=$passwordLogin, reverseGeocoding=$reverseGeocoding, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, trash=$trash]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -133,7 +127,6 @@ class ServerFeaturesDto {
json[r'oauthAutoLaunch'] = this.oauthAutoLaunch;
json[r'ocr'] = this.ocr;
json[r'passwordLogin'] = this.passwordLogin;
json[r'realtimeTranscoding'] = this.realtimeTranscoding;
json[r'reverseGeocoding'] = this.reverseGeocoding;
json[r'search'] = this.search;
json[r'sidecar'] = this.sidecar;
@@ -161,7 +154,6 @@ class ServerFeaturesDto {
oauthAutoLaunch: mapValueOfType<bool>(json, r'oauthAutoLaunch')!,
ocr: mapValueOfType<bool>(json, r'ocr')!,
passwordLogin: mapValueOfType<bool>(json, r'passwordLogin')!,
realtimeTranscoding: mapValueOfType<bool>(json, r'realtimeTranscoding')!,
reverseGeocoding: mapValueOfType<bool>(json, r'reverseGeocoding')!,
search: mapValueOfType<bool>(json, r'search')!,
sidecar: mapValueOfType<bool>(json, r'sidecar')!,
@@ -224,7 +216,6 @@ class ServerFeaturesDto {
'oauthAutoLaunch',
'ocr',
'passwordLogin',
'realtimeTranscoding',
'reverseGeocoding',
'search',
'sidecar',
+6 -22
View File
@@ -16,61 +16,47 @@ class ServerVersionResponseDto {
required this.major,
required this.minor,
required this.patch_,
required this.prerelease,
});
/// Major version number
///
/// Minimum value: 0
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int major;
/// Minor version number
///
/// Minimum value: 0
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int minor;
/// Patch version number
///
/// Minimum value: 0
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int patch_;
/// Pre-release version number
///
/// Minimum value: 0
/// Maximum value: 9007199254740991
int? prerelease;
@override
bool operator ==(Object other) => identical(this, other) || other is ServerVersionResponseDto &&
other.major == major &&
other.minor == minor &&
other.patch_ == patch_ &&
other.prerelease == prerelease;
other.patch_ == patch_;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(major.hashCode) +
(minor.hashCode) +
(patch_.hashCode) +
(prerelease == null ? 0 : prerelease!.hashCode);
(patch_.hashCode);
@override
String toString() => 'ServerVersionResponseDto[major=$major, minor=$minor, patch_=$patch_, prerelease=$prerelease]';
String toString() => 'ServerVersionResponseDto[major=$major, minor=$minor, patch_=$patch_]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'major'] = this.major;
json[r'minor'] = this.minor;
json[r'patch'] = this.patch_;
if (this.prerelease != null) {
json[r'prerelease'] = this.prerelease;
} else {
// json[r'prerelease'] = null;
}
return json;
}
@@ -86,7 +72,6 @@ class ServerVersionResponseDto {
major: mapValueOfType<int>(json, r'major')!,
minor: mapValueOfType<int>(json, r'minor')!,
patch_: mapValueOfType<int>(json, r'patch')!,
prerelease: mapValueOfType<int>(json, r'prerelease'),
);
}
return null;
@@ -137,7 +122,6 @@ class ServerVersionResponseDto {
'major',
'minor',
'patch',
'prerelease',
};
}
+1 -9
View File
@@ -25,7 +25,6 @@ class SystemConfigFFmpegDto {
required this.maxBitrate,
required this.preferredHwDevice,
required this.preset,
required this.realtime,
required this.refs,
required this.targetAudioCodec,
required this.targetResolution,
@@ -80,8 +79,6 @@ class SystemConfigFFmpegDto {
/// Preset
String preset;
SystemConfigFFmpegRealtimeDto realtime;
/// References
///
/// Minimum value: 0
@@ -125,7 +122,6 @@ class SystemConfigFFmpegDto {
other.maxBitrate == maxBitrate &&
other.preferredHwDevice == preferredHwDevice &&
other.preset == preset &&
other.realtime == realtime &&
other.refs == refs &&
other.targetAudioCodec == targetAudioCodec &&
other.targetResolution == targetResolution &&
@@ -151,7 +147,6 @@ class SystemConfigFFmpegDto {
(maxBitrate.hashCode) +
(preferredHwDevice.hashCode) +
(preset.hashCode) +
(realtime.hashCode) +
(refs.hashCode) +
(targetAudioCodec.hashCode) +
(targetResolution.hashCode) +
@@ -163,7 +158,7 @@ class SystemConfigFFmpegDto {
(twoPass.hashCode);
@override
String toString() => 'SystemConfigFFmpegDto[accel=$accel, accelDecode=$accelDecode, acceptedAudioCodecs=$acceptedAudioCodecs, acceptedContainers=$acceptedContainers, acceptedVideoCodecs=$acceptedVideoCodecs, bframes=$bframes, cqMode=$cqMode, crf=$crf, gopSize=$gopSize, maxBitrate=$maxBitrate, preferredHwDevice=$preferredHwDevice, preset=$preset, realtime=$realtime, refs=$refs, targetAudioCodec=$targetAudioCodec, targetResolution=$targetResolution, targetVideoCodec=$targetVideoCodec, temporalAQ=$temporalAQ, threads=$threads, tonemap=$tonemap, transcode=$transcode, twoPass=$twoPass]';
String toString() => 'SystemConfigFFmpegDto[accel=$accel, accelDecode=$accelDecode, acceptedAudioCodecs=$acceptedAudioCodecs, acceptedContainers=$acceptedContainers, acceptedVideoCodecs=$acceptedVideoCodecs, bframes=$bframes, cqMode=$cqMode, crf=$crf, gopSize=$gopSize, maxBitrate=$maxBitrate, preferredHwDevice=$preferredHwDevice, preset=$preset, refs=$refs, targetAudioCodec=$targetAudioCodec, targetResolution=$targetResolution, targetVideoCodec=$targetVideoCodec, temporalAQ=$temporalAQ, threads=$threads, tonemap=$tonemap, transcode=$transcode, twoPass=$twoPass]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -179,7 +174,6 @@ class SystemConfigFFmpegDto {
json[r'maxBitrate'] = this.maxBitrate;
json[r'preferredHwDevice'] = this.preferredHwDevice;
json[r'preset'] = this.preset;
json[r'realtime'] = this.realtime;
json[r'refs'] = this.refs;
json[r'targetAudioCodec'] = this.targetAudioCodec;
json[r'targetResolution'] = this.targetResolution;
@@ -213,7 +207,6 @@ class SystemConfigFFmpegDto {
maxBitrate: mapValueOfType<String>(json, r'maxBitrate')!,
preferredHwDevice: mapValueOfType<String>(json, r'preferredHwDevice')!,
preset: mapValueOfType<String>(json, r'preset')!,
realtime: SystemConfigFFmpegRealtimeDto.fromJson(json[r'realtime'])!,
refs: mapValueOfType<int>(json, r'refs')!,
targetAudioCodec: AudioCodec.fromJson(json[r'targetAudioCodec'])!,
targetResolution: mapValueOfType<String>(json, r'targetResolution')!,
@@ -282,7 +275,6 @@ class SystemConfigFFmpegDto {
'maxBitrate',
'preferredHwDevice',
'preset',
'realtime',
'refs',
'targetAudioCodec',
'targetResolution',
@@ -1,100 +0,0 @@
//
// 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;
class SystemConfigFFmpegRealtimeDto {
/// Returns a new [SystemConfigFFmpegRealtimeDto] instance.
SystemConfigFFmpegRealtimeDto({
required this.enabled,
});
/// Enable real-time HLS transcoding (alpha)
bool enabled;
@override
bool operator ==(Object other) => identical(this, other) || other is SystemConfigFFmpegRealtimeDto &&
other.enabled == enabled;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(enabled.hashCode);
@override
String toString() => 'SystemConfigFFmpegRealtimeDto[enabled=$enabled]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'enabled'] = this.enabled;
return json;
}
/// Returns a new [SystemConfigFFmpegRealtimeDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static SystemConfigFFmpegRealtimeDto? fromJson(dynamic value) {
upgradeDto(value, "SystemConfigFFmpegRealtimeDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return SystemConfigFFmpegRealtimeDto(
enabled: mapValueOfType<bool>(json, r'enabled')!,
);
}
return null;
}
static List<SystemConfigFFmpegRealtimeDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <SystemConfigFFmpegRealtimeDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = SystemConfigFFmpegRealtimeDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, SystemConfigFFmpegRealtimeDto> mapFromJson(dynamic json) {
final map = <String, SystemConfigFFmpegRealtimeDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = SystemConfigFFmpegRealtimeDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of SystemConfigFFmpegRealtimeDto-objects as value to a dart map
static Map<String, List<SystemConfigFFmpegRealtimeDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<SystemConfigFFmpegRealtimeDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = SystemConfigFFmpegRealtimeDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'enabled',
};
}
@@ -13,32 +13,26 @@ part of openapi.api;
class SystemConfigNewVersionCheckDto {
/// Returns a new [SystemConfigNewVersionCheckDto] instance.
SystemConfigNewVersionCheckDto({
required this.channel,
required this.enabled,
});
ReleaseChannel channel;
/// Enabled
bool enabled;
@override
bool operator ==(Object other) => identical(this, other) || other is SystemConfigNewVersionCheckDto &&
other.channel == channel &&
other.enabled == enabled;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(channel.hashCode) +
(enabled.hashCode);
@override
String toString() => 'SystemConfigNewVersionCheckDto[channel=$channel, enabled=$enabled]';
String toString() => 'SystemConfigNewVersionCheckDto[enabled=$enabled]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'channel'] = this.channel;
json[r'enabled'] = this.enabled;
return json;
}
@@ -52,7 +46,6 @@ class SystemConfigNewVersionCheckDto {
final json = value.cast<String, dynamic>();
return SystemConfigNewVersionCheckDto(
channel: ReleaseChannel.fromJson(json[r'channel'])!,
enabled: mapValueOfType<bool>(json, r'enabled')!,
);
}
@@ -101,7 +94,6 @@ class SystemConfigNewVersionCheckDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'channel',
'enabled',
};
}
+7 -4
View File
@@ -105,25 +105,26 @@ class CloudIdResult {
@HostApi()
abstract class NativeSyncApi {
@async
bool shouldFullSync();
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
@async
SyncDelta getMediaChanges();
void checkpointSync();
void clearSyncCheckpoint();
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
@async
List<String> getAssetIdsForAlbum(String albumId);
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
@async
List<PlatformAlbum> getAlbums();
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
int getAssetsCountSince(String albumId, int timestamp);
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
@async
List<PlatformAsset> getAssetsForAlbum(String albumId, {int? updatedTimeCond});
@async
@@ -132,6 +133,8 @@ abstract class NativeSyncApi {
void cancelHashing();
void cancelSync();
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
Map<String, List<PlatformAsset>> getTrashedAssets();
@@ -36,13 +36,6 @@ class _AbortCallbackWrapper {
class _MockAbortCallbackWrapper extends Mock implements _AbortCallbackWrapper {}
class _CancellationWrapper {
const _CancellationWrapper();
bool call() => false;
}
class _MockCancellationWrapper extends Mock implements _CancellationWrapper {}
void main() {
late SyncStreamService sut;
@@ -94,9 +87,13 @@ void main() {
when(() => mockAbortCallbackWrapper()).thenReturn(false);
when(() => mockSyncApiRepo.streamChanges(any(), serverVersion: any(named: 'serverVersion'))).thenAnswer((
invocation,
) async {
when(
() => mockSyncApiRepo.streamChanges(
any(),
serverVersion: any(named: 'serverVersion'),
abortSignal: any(named: 'abortSignal'),
),
).thenAnswer((invocation) async {
handleEventsCallback = invocation.positionalArguments.first;
});
@@ -105,6 +102,7 @@ void main() {
any(),
onReset: any(named: 'onReset'),
serverVersion: any(named: 'serverVersion'),
abortSignal: any(named: 'abortSignal'),
),
).thenAnswer((invocation) async {
handleEventsCallback = invocation.positionalArguments.first;
@@ -116,7 +114,7 @@ void main() {
when(() => mockApi.serverInfoApi).thenReturn(mockServerApi);
when(
() => mockServerApi.getServerVersion(),
).thenAnswer((_) async => ServerVersionResponseDto(major: 1, minor: 132, patch_: 0, prerelease: null));
).thenAnswer((_) async => ServerVersionResponseDto(major: 1, minor: 132, patch_: 0));
when(() => mockSyncStreamRepo.updateUsersV1(any())).thenAnswer(successHandler);
when(() => mockSyncStreamRepo.deleteUsersV1(any())).thenAnswer(successHandler);
@@ -233,8 +231,7 @@ void main() {
});
test("aborts and stops processing if cancelled during iteration", () async {
final cancellationChecker = _MockCancellationWrapper();
when(() => cancellationChecker()).thenReturn(false);
final cancellation = Completer<void>();
sut = SyncStreamService(
syncApiRepository: mockSyncApiRepo,
@@ -243,7 +240,7 @@ void main() {
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
assetMediaRepository: mockAssetMediaRepo,
permissionRepository: mockPermissionRepo,
cancelChecker: cancellationChecker.call,
cancellation: cancellation,
api: mockApi,
syncMigrationRepository: mockSyncMigrationRepo,
);
@@ -252,7 +249,7 @@ void main() {
final events = [SyncStreamStub.userDeleteV1, SyncStreamStub.userV1Admin, SyncStreamStub.partnerDeleteV1];
when(() => mockSyncStreamRepo.deleteUsersV1(any())).thenAnswer((_) async {
when(() => cancellationChecker()).thenReturn(true);
cancellation.complete();
});
await handleEventsCallback(events, mockAbortCallbackWrapper.call, mockResetCallbackWrapper.call);
@@ -267,8 +264,7 @@ void main() {
});
test("aborts and stops processing if cancelled before processing batch", () async {
final cancellationChecker = _MockCancellationWrapper();
when(() => cancellationChecker()).thenReturn(false);
final cancellation = Completer<void>();
final processingCompleter = Completer<void>();
bool handler1Started = false;
@@ -284,7 +280,7 @@ void main() {
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
assetMediaRepository: mockAssetMediaRepo,
permissionRepository: mockPermissionRepo,
cancelChecker: cancellationChecker.call,
cancellation: cancellation,
api: mockApi,
syncMigrationRepository: mockSyncMigrationRepo,
);
@@ -303,7 +299,7 @@ void main() {
expect(handler1Started, isTrue);
// Signal cancellation while handler 1 is waiting
when(() => cancellationChecker()).thenReturn(true);
cancellation.complete();
await pumpEventQueue();
processingCompleter.complete();
@@ -559,7 +555,7 @@ void main() {
await Store.put(StoreKey.syncMigrationStatus, "[]");
when(
() => mockServerApi.getServerVersion(),
).thenAnswer((_) async => ServerVersionResponseDto(major: 2, minor: 4, patch_: 1, prerelease: null));
).thenAnswer((_) async => ServerVersionResponseDto(major: 2, minor: 4, patch_: 1));
await sut.sync();
@@ -587,7 +583,7 @@ void main() {
await Store.put(StoreKey.syncMigrationStatus, "[]");
when(
() => mockServerApi.getServerVersion(),
).thenAnswer((_) async => ServerVersionResponseDto(major: 2, minor: 5, patch_: 0, prerelease: null));
).thenAnswer((_) async => ServerVersionResponseDto(major: 2, minor: 5, patch_: 0));
await sut.sync();
verifyInOrder([
@@ -617,7 +613,7 @@ void main() {
when(
() => mockServerApi.getServerVersion(),
).thenAnswer((_) async => ServerVersionResponseDto(major: 2, minor: 4, patch_: 1, prerelease: null));
).thenAnswer((_) async => ServerVersionResponseDto(major: 2, minor: 4, patch_: 1));
await sut.sync();
@@ -0,0 +1,23 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/wm_executor.dart';
void main() {
tearDown(workerManagerPatch.dispose);
test('dispose() drains a cancelled task and delivers its result', () async {
await workerManagerPatch.init(isolatesCount: 1, dynamicSpawning: false);
final task = workerManagerPatch.executeGentle((onCancel) async {
await onCancel.future;
return 'drained';
});
await workerManagerPatch.dispose();
expect(
await task.timeout(const Duration(seconds: 5)),
'drained',
reason: 'the worker must finish and return its result, not be killed mid-task',
);
});
}
-66
View File
@@ -88,71 +88,5 @@ void main() {
expect(version2.minor, 2);
expect(version2.patch, 3);
});
test('Orders later prerelease above earlier prerelease', () {
const rc1 = SemVer(major: 1, minor: 151, patch: 0, prerelease: 1);
const rc2 = SemVer(major: 1, minor: 151, patch: 0, prerelease: 2);
expect(rc2 > rc1, isTrue);
expect(rc1 < rc2, isTrue);
expect(rc1 == rc2, isFalse);
});
test('Final release outranks its prerelease of the same version', () {
const rc = SemVer(major: 1, minor: 151, patch: 0, prerelease: 1);
const release = SemVer(major: 1, minor: 151, patch: 0);
expect(release > rc, isTrue);
expect(rc < release, isTrue);
});
test('Higher major outranks a prerelease regardless of ordinal', () {
const rc = SemVer(major: 1, minor: 151, patch: 0, prerelease: 9);
const next = SemVer(major: 2, minor: 0, patch: 0);
expect(next > rc, isTrue);
});
test('Equal prerelease versions compare as equal', () {
const a = SemVer(major: 1, minor: 151, patch: 0, prerelease: 3);
const b = SemVer(major: 1, minor: 151, patch: 0, prerelease: 3);
expect(a == b, isTrue);
expect(a > b, isFalse);
expect(a < b, isFalse);
});
test('Reports prerelease difference type', () {
const rc1 = SemVer(major: 1, minor: 151, patch: 0, prerelease: 1);
const rc2 = SemVer(major: 1, minor: 151, patch: 0, prerelease: 2);
expect(rc1.differenceType(rc2), SemVerType.prerelease);
});
test('toString includes prerelease suffix when present', () {
const rc = SemVer(major: 1, minor: 151, patch: 0, prerelease: 2);
expect(rc.toString(), '1.151.0-rc.2');
});
test('Parses prerelease ordinal from -rc strings', () {
final dotted = SemVer.fromString('1.151.0-rc.2');
expect(dotted.major, 1);
expect(dotted.minor, 151);
expect(dotted.patch, 0);
expect(dotted.prerelease, 2);
expect(SemVer.fromString('v1.151.0-rc.3').prerelease, 3);
expect(SemVer.fromString('1.2.3-rc.2+build.5').prerelease, 2);
});
test('Plain version string has null prerelease', () {
expect(SemVer.fromString('3.0.0').prerelease, isNull);
});
test('Invalid rc suffixes parse without error and have null prerelease', () {
final debug = SemVer.fromString('1.2.3-debug');
expect(debug.major, 1);
expect(debug.minor, 2);
expect(debug.patch, 3);
expect(debug.prerelease, isNull);
expect(SemVer.fromString('1.2.3+build.5').prerelease, isNull);
expect(SemVer.fromString('1.151.0-rc4').prerelease, isNull);
});
});
}
+4 -440
View File
@@ -4308,351 +4308,6 @@
"x-immich-state": "Stable"
}
},
"/assets/{id}/video/stream/main.m3u8": {
"get": {
"description": "Returns an HLS main playlist with all available variants for the asset.",
"operationId": "getMainPlaylist",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
},
{
"name": "key",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "slug",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/vnd.apple.mpegurl": {
"schema": {
"type": "string"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Get HLS main playlist",
"tags": [
"Assets"
],
"x-immich-history": [
{
"version": "v3",
"state": "Added"
},
{
"version": "v3",
"state": "Alpha"
}
],
"x-immich-permission": "asset.view",
"x-immich-state": "Alpha"
}
},
"/assets/{id}/video/stream/{sessionId}": {
"delete": {
"description": "Releases server resources for the streaming session.",
"operationId": "endSession",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
},
{
"name": "key",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "sessionId",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
},
{
"name": "slug",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
}
],
"responses": {
"204": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "End HLS streaming session",
"tags": [
"Assets"
],
"x-immich-history": [
{
"version": "v3",
"state": "Added"
},
{
"version": "v3",
"state": "Alpha"
}
],
"x-immich-permission": "asset.view",
"x-immich-state": "Alpha"
}
},
"/assets/{id}/video/stream/{sessionId}/{variantIndex}/playlist.m3u8": {
"get": {
"description": "Returns an HLS media playlist for one variant of the streaming session.",
"operationId": "getMediaPlaylist",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
},
{
"name": "key",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "sessionId",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
},
{
"name": "slug",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "variantIndex",
"required": true,
"in": "path",
"schema": {
"minimum": 0,
"maximum": 9007199254740991,
"type": "integer"
}
}
],
"responses": {
"200": {
"content": {
"application/vnd.apple.mpegurl": {
"schema": {
"type": "string"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Get HLS media playlist",
"tags": [
"Assets"
],
"x-immich-history": [
{
"version": "v3",
"state": "Added"
},
{
"version": "v3",
"state": "Alpha"
}
],
"x-immich-permission": "asset.view",
"x-immich-state": "Alpha"
}
},
"/assets/{id}/video/stream/{sessionId}/{variantIndex}/{filename}": {
"get": {
"description": "Streams an HLS init segment (init.mp4) or media segment (seg_N.m4s).",
"operationId": "getSegment",
"parameters": [
{
"name": "filename",
"required": true,
"in": "path",
"schema": {
"pattern": "^(init\\.mp4|seg_\\d+\\.m4s)$",
"type": "string"
}
},
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
},
{
"name": "key",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "sessionId",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
},
{
"name": "slug",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "variantIndex",
"required": true,
"in": "path",
"schema": {
"minimum": 0,
"maximum": 9007199254740991,
"type": "integer"
}
}
],
"responses": {
"200": {
"content": {
"application/octet-stream": {
"schema": {
"format": "binary",
"type": "string"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Get HLS segment or init file",
"tags": [
"Assets"
],
"x-immich-history": [
{
"version": "v3",
"state": "Added"
},
{
"version": "v3",
"state": "Alpha"
}
],
"x-immich-permission": "asset.view",
"x-immich-state": "Alpha"
}
},
"/auth/admin-sign-up": {
"post": {
"description": "Create the first admin user in the system.",
@@ -18491,7 +18146,6 @@
"LibrarySyncFilesQueueAll",
"LibrarySyncFiles",
"LibraryScanQueueAll",
"HlsSessionCleanup",
"MemoryCleanup",
"MemoryGenerate",
"NotificationsCleanup",
@@ -21174,57 +20828,6 @@
],
"type": "string"
},
"ReleaseChannel": {
"description": "Release channel",
"enum": [
"stable",
"releaseCandidate"
],
"type": "string"
},
"ReleaseEventV1": {
"properties": {
"checkedAt": {
"description": "When the server last checked for a latest version. As an ISO timestamp",
"type": "string"
},
"isAvailable": {
"description": "Whether a new version is available",
"type": "boolean"
},
"releaseVersion": {
"$ref": "#/components/schemas/ServerVersionResponseDto"
},
"serverVersion": {
"$ref": "#/components/schemas/ServerVersionResponseDto"
},
"type": {
"$ref": "#/components/schemas/ReleaseType",
"description": "Release type",
"nullable": true
}
},
"required": [
"checkedAt",
"isAvailable",
"releaseVersion",
"serverVersion",
"type"
],
"type": "object"
},
"ReleaseType": {
"enum": [
"major",
"premajor",
"minor",
"preminor",
"patch",
"prepatch",
"prerelease"
],
"type": "string"
},
"ReverseGeocodingStateResponseDto": {
"properties": {
"lastImportFileName": {
@@ -21682,10 +21285,6 @@
"description": "Whether password login is enabled",
"type": "boolean"
},
"realtimeTranscoding": {
"description": "Whether real-time transcoding is enabled",
"type": "boolean"
},
"reverseGeocoding": {
"description": "Whether reverse geocoding is enabled",
"type": "boolean"
@@ -21718,7 +21317,6 @@
"oauthAutoLaunch",
"ocr",
"passwordLogin",
"realtimeTranscoding",
"reverseGeocoding",
"search",
"sidecar",
@@ -21899,40 +21497,26 @@
"major": {
"description": "Major version number",
"maximum": 9007199254740991,
"minimum": 0,
"minimum": -9007199254740991,
"type": "integer"
},
"minor": {
"description": "Minor version number",
"maximum": 9007199254740991,
"minimum": 0,
"minimum": -9007199254740991,
"type": "integer"
},
"patch": {
"description": "Patch version number",
"maximum": 9007199254740991,
"minimum": 0,
"minimum": -9007199254740991,
"type": "integer"
},
"prerelease": {
"description": "Pre-release version number",
"maximum": 9007199254740991,
"minimum": 0,
"nullable": true,
"type": "integer",
"x-immich-history": [
{
"version": "v3.0.0",
"state": "Added"
}
]
}
},
"required": [
"major",
"minor",
"patch",
"prerelease"
"patch"
],
"type": "object"
},
@@ -24593,9 +24177,6 @@
"description": "Preset",
"type": "string"
},
"realtime": {
"$ref": "#/components/schemas/SystemConfigFFmpegRealtimeDto"
},
"refs": {
"description": "References",
"maximum": 6,
@@ -24646,7 +24227,6 @@
"maxBitrate",
"preferredHwDevice",
"preset",
"realtime",
"refs",
"targetAudioCodec",
"targetResolution",
@@ -24659,18 +24239,6 @@
],
"type": "object"
},
"SystemConfigFFmpegRealtimeDto": {
"properties": {
"enabled": {
"description": "Enable real-time HLS transcoding (alpha)",
"type": "boolean"
}
},
"required": [
"enabled"
],
"type": "object"
},
"SystemConfigFacesDto": {
"properties": {
"import": {
@@ -24969,16 +24537,12 @@
},
"SystemConfigNewVersionCheckDto": {
"properties": {
"channel": {
"$ref": "#/components/schemas/ReleaseChannel"
},
"enabled": {
"description": "Enabled",
"type": "boolean"
}
},
"required": [
"channel",
"enabled"
],
"type": "object"
+1
View File
@@ -232,6 +232,7 @@
"albumName": {
"type": "string",
"title": "Album name",
"array": true,
"description": "Use an album with this name if one exists, otherwise create a new one"
}
},
-110
View File
@@ -1993,8 +1993,6 @@ export type ServerFeaturesDto = {
ocr: boolean;
/** Whether password login is enabled */
passwordLogin: boolean;
/** Whether real-time transcoding is enabled */
realtimeTranscoding: boolean;
/** Whether reverse geocoding is enabled */
reverseGeocoding: boolean;
/** Whether search is enabled */
@@ -2078,8 +2076,6 @@ export type ServerVersionResponseDto = {
minor: number;
/** Patch version number */
patch: number;
/** Pre-release version number */
prerelease: number | null;
};
export type VersionCheckStateResponseDto = {
/** Last check timestamp */
@@ -2255,10 +2251,6 @@ export type DatabaseBackupConfig = {
export type SystemConfigBackupsDto = {
database: DatabaseBackupConfig;
};
export type SystemConfigFFmpegRealtimeDto = {
/** Enable real-time HLS transcoding (alpha) */
enabled: boolean;
};
export type SystemConfigFFmpegDto = {
accel: TranscodeHWAccel;
/** Accelerated decode */
@@ -2282,7 +2274,6 @@ export type SystemConfigFFmpegDto = {
preferredHwDevice: string;
/** Preset */
preset: string;
realtime: SystemConfigFFmpegRealtimeDto;
/** References */
refs: number;
targetAudioCodec: AudioCodec;
@@ -2432,7 +2423,6 @@ export type SystemConfigMetadataDto = {
faces: SystemConfigFacesDto;
};
export type SystemConfigNewVersionCheckDto = {
channel: ReleaseChannel;
/** Enabled */
enabled: boolean;
};
@@ -2778,16 +2768,6 @@ export type WorkflowShareResponseDto = {
trigger: WorkflowTrigger;
};
export type LicenseResponseDto = UserLicense;
export type ReleaseEventV1 = {
/** When the server last checked for a latest version. As an ISO timestamp */
checkedAt: string;
/** Whether a new version is available */
isAvailable: boolean;
releaseVersion: ServerVersionResponseDto;
serverVersion: ServerVersionResponseDto;
/** Release type */
"type": ReleaseType;
};
export type SyncAckV1 = {};
export type SyncAlbumDeleteV1 = {
/** Album ID */
@@ -4232,82 +4212,6 @@ export function playAssetVideo({ id, key, slug }: {
...opts
}));
}
/**
* Get HLS main playlist
*/
export function getMainPlaylist({ id, key, slug }: {
id: string;
key?: string;
slug?: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchBlob<{
status: 200;
data: string;
}>(`/assets/${encodeURIComponent(id)}/video/stream/main.m3u8${QS.query(QS.explode({
key,
slug
}))}`, {
...opts
}));
}
/**
* End HLS streaming session
*/
export function endSession({ id, key, sessionId, slug }: {
id: string;
key?: string;
sessionId: string;
slug?: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText(`/assets/${encodeURIComponent(id)}/video/stream/${encodeURIComponent(sessionId)}${QS.query(QS.explode({
key,
slug
}))}`, {
...opts,
method: "DELETE"
}));
}
/**
* Get HLS media playlist
*/
export function getMediaPlaylist({ id, key, sessionId, slug, variantIndex }: {
id: string;
key?: string;
sessionId: string;
slug?: string;
variantIndex: number;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchBlob<{
status: 200;
data: string;
}>(`/assets/${encodeURIComponent(id)}/video/stream/${encodeURIComponent(sessionId)}/${encodeURIComponent(variantIndex)}/playlist.m3u8${QS.query(QS.explode({
key,
slug
}))}`, {
...opts
}));
}
/**
* Get HLS segment or init file
*/
export function getSegment({ filename, id, key, sessionId, slug, variantIndex }: {
filename: string;
id: string;
key?: string;
sessionId: string;
slug?: string;
variantIndex: number;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchBlob<{
status: 200;
data: Blob;
}>(`/assets/${encodeURIComponent(id)}/video/stream/${encodeURIComponent(sessionId)}/${encodeURIComponent(variantIndex)}/${encodeURIComponent(filename)}${QS.query(QS.explode({
key,
slug
}))}`, {
...opts
}));
}
/**
* Register admin
*/
@@ -7218,7 +7122,6 @@ export enum JobName {
LibrarySyncFilesQueueAll = "LibrarySyncFilesQueueAll",
LibrarySyncFiles = "LibrarySyncFiles",
LibraryScanQueueAll = "LibraryScanQueueAll",
HlsSessionCleanup = "HlsSessionCleanup",
MemoryCleanup = "MemoryCleanup",
MemoryGenerate = "MemoryGenerate",
NotificationsCleanup = "NotificationsCleanup",
@@ -7409,10 +7312,6 @@ export enum LogLevel {
Error = "error",
Fatal = "fatal"
}
export enum ReleaseChannel {
Stable = "stable",
ReleaseCandidate = "releaseCandidate"
}
export enum OAuthTokenEndpointAuthMethod {
ClientSecretPost = "client_secret_post",
ClientSecretBasic = "client_secret_basic"
@@ -7421,15 +7320,6 @@ export enum AssetOrderBy {
TakenAt = "takenAt",
CreatedAt = "createdAt"
}
export enum ReleaseType {
Major = "major",
Premajor = "premajor",
Minor = "minor",
Preminor = "preminor",
Patch = "patch",
Prepatch = "prepatch",
Prerelease = "prerelease"
}
export enum UserMetadataKey {
Preferences = "preferences",
License = "license",
+20 -57
View File
@@ -577,8 +577,8 @@ importers:
specifier: ^1.6.3
version: 1.6.4
semver:
specifier: ^7.8.1
version: 7.8.1
specifier: ^7.6.2
version: 7.8.0
sharp:
specifier: ^0.34.5
version: 0.34.5
@@ -820,12 +820,6 @@ importers:
happy-dom:
specifier: ^20.0.0
version: 20.9.0
hls-video-element:
specifier: ^1.5.11
version: 1.5.11
hls.js:
specifier: ^1.6.16
version: 1.6.16
intl-messageformat:
specifier: ^11.0.0
version: 11.2.6
@@ -6953,9 +6947,6 @@ packages:
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
custom-media-element@1.4.6:
resolution: {integrity: sha512-/HRYqJOa1ob5ik4q7FIJVYxTJCFs/FL3+cQPAJjUf2uiqrDEzbTgB315gQ2rG8oK3w094W9m5tcB8S5Qah+caA==}
cytoscape-cose-bilkent@4.1.0:
resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==}
peerDependencies:
@@ -8280,12 +8271,6 @@ packages:
history@4.10.1:
resolution: {integrity: sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==}
hls-video-element@1.5.11:
resolution: {integrity: sha512-tJJ65/52CDxj8XFyIve6zT9nVVdUIc6mqvKR25X0ycPKHk07rpjp4xxVteeCefDUBSf/tFLhlICFmn3KWj37xA==}
hls.js@1.6.16:
resolution: {integrity: sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==}
hogan.js@3.0.2:
resolution: {integrity: sha512-RqGs4wavGYJWE07t35JQccByczmNUXQT0E12ZYV1VKYu5UiAU9lsos/yBAcf840+zrUQQxgVduCR5/B8nNtibg==}
hasBin: true
@@ -9290,9 +9275,6 @@ packages:
media-chrome@4.19.0:
resolution: {integrity: sha512-HWhDTwts+BSbdPkkB1VsJXp5kvL0IxY7xFT5tBwliM2+89kTPVTnHnev+9it2f9PweANjT/C8/C/S0PW9oyZbA==}
media-tracks@0.3.5:
resolution: {integrity: sha512-l54rkKXlLBt3ob3zOLWHcnjvwUmX5bNEZ70igyapOZZC9imzqBmq1oz8p2roiV04KhjblFIi2hetLPF1oYVLRA==}
media-typer@0.3.0:
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
engines: {node: '>= 0.6'}
@@ -11268,11 +11250,6 @@ packages:
engines: {node: '>=10'}
hasBin: true
semver@7.8.1:
resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==}
engines: {node: '>=10'}
hasBin: true
send@0.19.2:
resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==}
engines: {node: '>= 0.8.0'}
@@ -16330,7 +16307,7 @@ snapshots:
nopt: 5.0.0
npmlog: 5.0.1
rimraf: 3.0.2
semver: 7.8.1
semver: 7.8.0
tar: 6.2.1
transitivePeerDependencies:
- encoding
@@ -17788,7 +17765,7 @@ snapshots:
'@testing-library/dom@10.4.1':
dependencies:
'@babel/code-frame': 7.29.0
'@babel/runtime': 7.29.7
'@babel/runtime': 7.29.2
'@types/aria-query': 5.0.4
aria-query: 5.3.0
dom-accessibility-api: 0.5.16
@@ -18492,7 +18469,7 @@ snapshots:
'@typescript-eslint/visitor-keys': 8.59.4
debug: 4.4.3
minimatch: 10.2.5
semver: 7.8.1
semver: 7.8.0
tinyglobby: 0.2.16
ts-api-utils: 2.5.0(typescript@6.0.3)
typescript: 6.0.3
@@ -19592,7 +19569,7 @@ snapshots:
dot-prop: 10.1.0
env-paths: 3.0.0
json-schema-typed: 8.0.2
semver: 7.8.1
semver: 7.8.0
uint8array-extras: 1.5.0
config-chain@1.1.13:
@@ -19764,7 +19741,7 @@ snapshots:
postcss-modules-scope: 3.2.1(postcss@8.5.15)
postcss-modules-values: 4.0.0(postcss@8.5.15)
postcss-value-parser: 4.2.0
semver: 7.8.1
semver: 7.8.0
optionalDependencies:
webpack: 5.107.0(postcss@8.5.15)
@@ -19887,8 +19864,6 @@ snapshots:
csstype@3.2.3: {}
custom-media-element@1.4.6: {}
cytoscape-cose-bilkent@4.1.0(cytoscape@3.33.4):
dependencies:
cose-base: 1.0.3
@@ -20634,7 +20609,7 @@ snapshots:
find-up: 5.0.0
globals: 15.15.0
lodash.memoize: 4.1.2
semver: 7.8.1
semver: 7.8.0
eslint-plugin-prettier@5.5.5(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@10.4.0(jiti@2.7.0)))(eslint@10.4.0(jiti@2.7.0))(prettier@3.8.3):
dependencies:
@@ -20657,7 +20632,7 @@ snapshots:
postcss: 8.5.15
postcss-load-config: 3.1.4(postcss@8.5.15)
postcss-safe-parser: 7.0.1(postcss@8.5.15)
semver: 7.8.1
semver: 7.8.0
svelte-eslint-parser: 1.6.1(svelte@5.55.8(@typescript-eslint/types@8.59.4))
optionalDependencies:
svelte: 5.55.8(@typescript-eslint/types@8.59.4)
@@ -21135,7 +21110,7 @@ snapshots:
minimatch: 3.1.5
node-abort-controller: 3.1.1
schema-utils: 3.3.0
semver: 7.8.1
semver: 7.8.0
tapable: 2.3.3
typescript: 5.9.3
webpack: 5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.22))(esbuild@0.28.0)(lightningcss@1.32.0)
@@ -21571,21 +21546,13 @@ snapshots:
history@4.10.1:
dependencies:
'@babel/runtime': 7.29.7
'@babel/runtime': 7.29.2
loose-envify: 1.4.0
resolve-pathname: 3.0.0
tiny-invariant: 1.3.3
tiny-warning: 1.0.3
value-equal: 1.0.1
hls-video-element@1.5.11:
dependencies:
custom-media-element: 1.4.6
hls.js: 1.6.16
media-tracks: 0.3.5
hls.js@1.6.16: {}
hogan.js@3.0.2:
dependencies:
mkdirp: 0.3.0
@@ -22167,7 +22134,7 @@ snapshots:
lodash.isstring: 4.0.1
lodash.once: 4.1.1
ms: 2.1.3
semver: 7.8.1
semver: 7.8.0
just-compare@2.3.0: {}
@@ -22453,7 +22420,7 @@ snapshots:
make-dir@4.0.0:
dependencies:
semver: 7.8.1
semver: 7.8.0
maplibre-gl@5.24.0:
dependencies:
@@ -22689,8 +22656,6 @@ snapshots:
transitivePeerDependencies:
- react
media-tracks@0.3.5: {}
media-typer@0.3.0: {}
media-typer@1.1.0: {}
@@ -23290,7 +23255,7 @@ snapshots:
node-abi@3.92.0:
dependencies:
semver: 7.8.1
semver: 7.8.0
optional: true
node-abort-controller@3.1.1: {}
@@ -23331,7 +23296,7 @@ snapshots:
graceful-fs: 4.2.11
nopt: 9.0.0
proc-log: 6.1.0
semver: 7.8.1
semver: 7.8.0
tar: 7.5.15
tinyglobby: 0.2.16
undici: 6.25.0
@@ -23569,7 +23534,7 @@ snapshots:
got: 12.6.1
registry-auth-token: 5.1.1
registry-url: 6.0.1
semver: 7.8.1
semver: 7.8.0
package-manager-detector@1.6.0: {}
@@ -23957,7 +23922,7 @@ snapshots:
cosmiconfig: 8.3.6(typescript@6.0.3)
jiti: 1.21.7
postcss: 8.5.15
semver: 7.8.1
semver: 7.8.0
webpack: 5.107.0(postcss@8.5.15)
transitivePeerDependencies:
- typescript
@@ -25012,14 +24977,12 @@ snapshots:
semver-diff@4.0.0:
dependencies:
semver: 7.8.1
semver: 7.8.0
semver@6.3.1: {}
semver@7.8.0: {}
semver@7.8.1: {}
send@0.19.2:
dependencies:
debug: 2.6.9
@@ -25554,7 +25517,7 @@ snapshots:
postcss: 8.5.15
postcss-scss: 4.0.9(postcss@8.5.15)
postcss-selector-parser: 7.1.1
semver: 7.8.1
semver: 7.8.0
optionalDependencies:
svelte: 5.55.8(@typescript-eslint/types@8.59.4)
@@ -26262,7 +26225,7 @@ snapshots:
is-yarn-global: 0.4.1
latest-version: 7.0.0
pupa: 3.3.0
semver: 7.8.1
semver: 7.8.0
semver-diff: 4.0.0
xdg-basedir: 5.1.0
+1 -1
View File
@@ -56,7 +56,7 @@ FROM builder AS plugins
ARG TARGETPLATFORM
COPY --from=ghcr.io/jdx/mise:2026.5.18@sha256:5bb3311994fa78cef307ca3077cdb18f9551da0886371fc26ea91ab56220ffc5 /usr/local/bin/mise /usr/local/bin/mise
COPY --from=ghcr.io/jdx/mise:2026.5.11@sha256:2ba959e4827f845fe0c4cfb4814089e790dc513040ef74f9e14925f446412a51 /usr/local/bin/mise /usr/local/bin/mise
WORKDIR /app
COPY ./mise.toml ./mise.toml
+1 -1
View File
@@ -2,7 +2,7 @@
FROM ghcr.io/immich-app/base-server-dev:202605051129@sha256:d07d8fcdb7e9f3ac22a811e87761ebf341ed0bb91956b89097540c2ed3fb9ca3 AS dev
COPY --from=ghcr.io/jdx/mise:2026.5.18@sha256:5bb3311994fa78cef307ca3077cdb18f9551da0886371fc26ea91ab56220ffc5 /usr/local/bin/mise /usr/local/bin/mise
COPY --from=ghcr.io/jdx/mise:2026.5.11@sha256:2ba959e4827f845fe0c4cfb4814089e790dc513040ef74f9e14925f446412a51 /usr/local/bin/mise /usr/local/bin/mise
RUN echo "devdir=/buildcache/node-gyp" >> /usr/local/etc/npmrc && \
echo "store-dir=/buildcache/pnpm-store" >> /usr/local/etc/npmrc && \
+1 -1
View File
@@ -106,7 +106,7 @@
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1",
"sanitize-filename": "^1.6.3",
"semver": "^7.8.1",
"semver": "^7.6.2",
"sharp": "^0.34.5",
"sirv": "^3.0.0",
"socket.io": "^4.8.1",
-9
View File
@@ -1,5 +1,4 @@
import { CronExpression } from '@nestjs/schedule';
import { ReleaseChannel } from 'src/dtos/system-config.dto';
import {
AudioCodec,
Colorspace,
@@ -46,9 +45,6 @@ export type SystemConfig = {
accel: TranscodeHardwareAcceleration;
accelDecode: boolean;
tonemap: ToneMapping;
realtime: {
enabled: boolean;
};
};
job: Record<ConcurrentQueueName, { concurrency: number }>;
logging: {
@@ -139,7 +135,6 @@ export type SystemConfig = {
};
newVersionCheck: {
enabled: boolean;
channel: ReleaseChannel;
};
nightlyTasks: {
startTime: string;
@@ -229,9 +224,6 @@ export const defaults = Object.freeze<SystemConfig>({
tonemap: ToneMapping.Hable,
accel: TranscodeHardwareAcceleration.Disabled,
accelDecode: true,
realtime: {
enabled: false,
},
},
job: {
[QueueName.BackgroundTask]: { concurrency: 5 },
@@ -352,7 +344,6 @@ export const defaults = Object.freeze<SystemConfig>({
},
newVersionCheck: {
enabled: true,
channel: ReleaseChannel.Stable,
},
nightlyTasks: {
startTime: '00:00',
+1 -38
View File
@@ -1,15 +1,7 @@
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { SemVer } from 'semver';
import {
ApiTag,
AudioCodec,
DatabaseExtension,
ExifOrientation,
TranscodeHardwareAcceleration,
VectorIndex,
VideoCodec,
} from 'src/enum';
import { ApiTag, AudioCodec, DatabaseExtension, ExifOrientation, VectorIndex } from 'src/enum';
export const IMMICH_SERVER_START = 'Immich Server is listening';
@@ -210,32 +202,3 @@ export const AUDIO_ENCODER: Record<AudioCodec, string> = {
[AudioCodec.Opus]: 'libopus',
[AudioCodec.PcmS16le]: 'pcm_s16le',
};
export const SUPPORTED_HWA_CODECS: Record<TranscodeHardwareAcceleration, VideoCodec[]> = {
[TranscodeHardwareAcceleration.Nvenc]: [VideoCodec.H264, VideoCodec.Hevc, VideoCodec.Av1],
[TranscodeHardwareAcceleration.Qsv]: [VideoCodec.H264, VideoCodec.Hevc, VideoCodec.Vp9, VideoCodec.Av1],
[TranscodeHardwareAcceleration.Vaapi]: [VideoCodec.H264, VideoCodec.Hevc, VideoCodec.Vp9, VideoCodec.Av1],
[TranscodeHardwareAcceleration.Rkmpp]: [VideoCodec.H264, VideoCodec.Hevc],
[TranscodeHardwareAcceleration.Disabled]: [VideoCodec.H264, VideoCodec.Hevc, VideoCodec.Vp9, VideoCodec.Av1],
};
export const HLS_BACKPRESSURE_PAUSE_SEGMENTS = 30;
export const HLS_BACKPRESSURE_RESUME_SEGMENTS = 15;
export const HLS_CLEANUP_INTERVAL_MS = 60 * 1000;
export const HLS_INACTIVITY_TIMEOUT_MS = 5 * 60 * 1000;
export const HLS_LEASE_DURATION_MS = 30 * 60 * 1000;
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' },
];
export const HLS_VERSION = 7;
-2
View File
@@ -35,7 +35,6 @@ import { TimelineController } from 'src/controllers/timeline.controller';
import { TrashController } from 'src/controllers/trash.controller';
import { UserAdminController } from 'src/controllers/user-admin.controller';
import { UserController } from 'src/controllers/user.controller';
import { VideoStreamController } from 'src/controllers/video-stream.controller';
import { ViewController } from 'src/controllers/view.controller';
import { WorkflowController } from 'src/controllers/workflow.controller';
@@ -77,7 +76,6 @@ export const controllers = [
TrashController,
UserAdminController,
UserController,
VideoStreamController,
ViewController,
WorkflowController,
];
@@ -1,79 +0,0 @@
import { Controller, Delete, Get, Header, HttpCode, HttpStatus, Next, Param, Res } from '@nestjs/common';
import { ApiProduces, ApiTags } from '@nestjs/swagger';
import { NextFunction, Response } from 'express';
import { HLS_PLAYLIST_CONTENT_TYPE } from 'src/constants';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import { HlsSegmentParamDto, HlsSessionParamDto, HlsVariantParamDto } from 'src/dtos/streaming.dto';
import { ApiTag, Permission, RouteKey } from 'src/enum';
import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { HlsService } from 'src/services/hls.service';
import { sendFile } from 'src/utils/file';
import { UUIDParamDto } from 'src/validation';
@ApiTags(ApiTag.Assets)
@Controller(RouteKey.Asset)
export class VideoStreamController {
constructor(
private logger: LoggingRepository,
private service: HlsService,
) {}
@Get(':id/video/stream/main.m3u8')
@Authenticated({ permission: Permission.AssetView, sharedLink: true })
@Header('Cache-Control', 'no-cache')
@Header('Content-Type', HLS_PLAYLIST_CONTENT_TYPE)
@ApiProduces(HLS_PLAYLIST_CONTENT_TYPE)
@Endpoint({
summary: 'Get HLS main playlist',
description: 'Returns an HLS main playlist with all available variants for the asset.',
history: new HistoryBuilder().added('v3').alpha('v3'),
})
getMainPlaylist(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto) {
return this.service.getMainPlaylist(auth, id);
}
@Get(':id/video/stream/:sessionId/:variantIndex/playlist.m3u8')
@Authenticated({ permission: Permission.AssetView, sharedLink: true })
@Header('Cache-Control', 'no-cache')
@Header('Content-Type', HLS_PLAYLIST_CONTENT_TYPE)
@ApiProduces(HLS_PLAYLIST_CONTENT_TYPE)
@Endpoint({
summary: 'Get HLS media playlist',
description: 'Returns an HLS media playlist for one variant of the streaming session.',
history: new HistoryBuilder().added('v3').alpha('v3'),
})
getMediaPlaylist(@Auth() auth: AuthDto, @Param() { id, sessionId }: HlsVariantParamDto) {
return this.service.getMediaPlaylist(auth, id, sessionId);
}
@Get(':id/video/stream/:sessionId/:variantIndex/:filename')
@FileResponse()
@Authenticated({ permission: Permission.AssetView, sharedLink: true })
@Endpoint({
summary: 'Get HLS segment or init file',
description: 'Streams an HLS init segment (init.mp4) or media segment (seg_N.m4s).',
history: new HistoryBuilder().added('v3').alpha('v3'),
})
async getSegment(
@Auth() auth: AuthDto,
@Param() { id, sessionId, variantIndex, filename }: HlsSegmentParamDto,
@Res() res: Response,
@Next() next: NextFunction,
) {
await sendFile(res, next, () => this.service.getSegment(auth, id, sessionId, variantIndex, filename), this.logger);
}
@Delete(':id/video/stream/:sessionId')
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated({ permission: Permission.AssetView, sharedLink: true })
@Endpoint({
summary: 'End HLS streaming session',
description: 'Releases server resources for the streaming session.',
history: new HistoryBuilder().added('v3').alpha('v3'),
})
async endSession(@Auth() auth: AuthDto, @Param() { id, sessionId }: HlsSessionParamDto) {
await this.service.endSession(auth, id, sessionId);
}
}
-12
View File
@@ -35,10 +35,6 @@ export interface MoveRequest {
export type ThumbnailPathEntity = { id: string; ownerId: string };
export type HlsSessionFolder = { ownerId: string; sessionId: string };
export type HlsVariantFolder = { ownerId: string; sessionId: string; variantIndex: number };
export type ImagePathOptions = { fileType: AssetFileType; format: ImageFormat | RawExtractedFormat; isEdited: boolean };
let instance: StorageCore | null;
@@ -129,14 +125,6 @@ export class StorageCore {
return StorageCore.getNestedPath(StorageFolder.EncodedVideo, asset.ownerId, `${asset.id}.mp4`);
}
static getHlsSessionFolder({ ownerId, sessionId }: HlsSessionFolder) {
return StorageCore.getNestedPath(StorageFolder.EncodedVideo, ownerId, sessionId);
}
static getHlsVariantFolder({ ownerId, sessionId, variantIndex }: HlsVariantFolder) {
return join(StorageCore.getHlsSessionFolder({ ownerId, sessionId }), variantIndex.toString());
}
static getAndroidMotionPath(asset: ThumbnailPathEntity, uuid: string) {
return StorageCore.getNestedPath(StorageFolder.EncodedVideo, asset.ownerId, `${uuid}-MP.mp4`);
}
-10
View File
@@ -265,13 +265,3 @@ export class HistoryBuilder {
return this;
}
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
export const extraModels: Function[] = [];
export const ExtraModel = (): ClassDecorator => {
// eslint-disable-next-line unicorn/consistent-function-scoping, @typescript-eslint/no-unsafe-function-type
return (object: Function) => {
extraModels.push(object);
};
};
+11 -39
View File
@@ -1,6 +1,5 @@
import { createZodDto } from 'nestjs-zod';
import type { SemVer } from 'semver';
import { ExtraModel, HistoryBuilder } from 'src/decorators';
import { isoDatetimeToDate } from 'src/validation';
import z from 'zod';
@@ -59,15 +58,9 @@ const ServerStorageResponseSchema = z
const ServerVersionResponseSchema = z
.object({
major: z.int().min(0).describe('Major version number'),
minor: z.int().min(0).describe('Minor version number'),
patch: z.int().min(0).describe('Patch version number'),
prerelease: z
.int()
.min(0)
.nullable()
.meta(HistoryBuilder.v3().getExtensions())
.describe('Pre-release version number'),
major: z.int().describe('Major version number'),
minor: z.int().describe('Minor version number'),
patch: z.int().describe('Patch version number'),
})
.meta({ id: 'ServerVersionResponseDto' });
@@ -144,30 +137,9 @@ const ServerFeaturesSchema = z
search: z.boolean().describe('Whether search is enabled'),
email: z.boolean().describe('Whether email notifications are enabled'),
ocr: z.boolean().describe('Whether OCR is enabled'),
realtimeTranscoding: z.boolean().describe('Whether real-time transcoding is enabled'),
})
.meta({ id: 'ServerFeaturesDto' });
export enum ReleaseType {
Major = 'major',
Premajor = 'premajor',
Minor = 'minor',
Preminor = 'preminor',
Patch = 'patch',
Prepatch = 'prepatch',
Prerelease = 'prerelease',
}
const ReleaseTypeSchema = z.enum(ReleaseType).meta({ id: 'ReleaseType' }).describe('Release type');
const ReleaseEventV1Schema = z.object({
isAvailable: z.boolean().describe('Whether a new version is available'),
checkedAt: z.string().describe('When the server last checked for a latest version. As an ISO timestamp'),
serverVersion: ServerVersionResponseSchema,
releaseVersion: ServerVersionResponseSchema,
type: ReleaseTypeSchema.nullable(),
});
export class ServerPingResponse extends createZodDto(ServerPingResponseSchema) {}
export class ServerAboutResponseDto extends createZodDto(ServerAboutResponseSchema) {}
export class ServerApkLinksDto extends createZodDto(ServerApkLinksSchema) {}
@@ -175,12 +147,7 @@ export class ServerStorageResponseDto extends createZodDto(ServerStorageResponse
export class ServerVersionResponseDto extends createZodDto(ServerVersionResponseSchema) {
static fromSemVer(value: SemVer): z.infer<typeof ServerVersionResponseSchema> {
return {
major: value.major,
minor: value.minor,
patch: value.patch,
prerelease: (value.prerelease[1] as number) ?? null,
};
return { major: value.major, minor: value.minor, patch: value.patch };
}
}
@@ -191,5 +158,10 @@ export class ServerMediaTypesResponseDto extends createZodDto(ServerMediaTypesRe
export class ServerConfigDto extends createZodDto(ServerConfigSchema) {}
export class ServerFeaturesDto extends createZodDto(ServerFeaturesSchema) {}
@ExtraModel()
export class ReleaseEventV1 extends createZodDto(ReleaseEventV1Schema) {}
export interface ReleaseNotification {
isAvailable: boolean;
/** ISO8601 */
checkedAt: string;
serverVersion: ServerVersionResponseDto;
releaseVersion: ServerVersionResponseDto;
}
-26
View File
@@ -1,26 +0,0 @@
import { createZodDto } from 'nestjs-zod';
import z from 'zod';
const HlsSessionParamSchema = z.object({
id: z.uuidv4(),
sessionId: z.uuidv4(),
});
export class HlsSessionParamDto extends createZodDto(HlsSessionParamSchema) {}
const HlsVariantParamSchema = z.object({
id: z.uuidv4(),
sessionId: z.uuidv4(),
variantIndex: z.coerce.number().int().min(0),
});
export class HlsVariantParamDto extends createZodDto(HlsVariantParamSchema) {}
const HlsSegmentParamSchema = z.object({
id: z.uuidv4(),
sessionId: z.uuidv4(),
variantIndex: z.coerce.number().int().min(0),
filename: z.string().regex(/^(init\.mp4|seg_\d+\.m4s)$/, { error: 'Invalid HLS segment filename' }),
});
export class HlsSegmentParamDto extends createZodDto(HlsSegmentParamSchema) {}
+10 -1
View File
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-unsafe-function-type */
import { createZodDto } from 'nestjs-zod';
import { ExtraModel } from 'src/decorators';
import { AssetEditActionSchema } from 'src/dtos/editing.dto';
import {
AlbumUserRole,
@@ -17,6 +17,15 @@ import {
import { isoDatetimeToDate } from 'src/validation';
import z from 'zod';
export const extraSyncModels: Function[] = [];
const ExtraModel = (): ClassDecorator => {
// eslint-disable-next-line unicorn/consistent-function-scoping
return (object: Function) => {
extraSyncModels.push(object);
};
};
const SyncUserV1Schema = z
.object({
id: z.string().describe('User ID'),
+1 -13
View File
@@ -79,11 +79,6 @@ const SystemConfigFFmpegSchema = z
accel: TranscodeHardwareAccelerationSchema,
accelDecode: configBool.describe('Accelerated decode'),
tonemap: ToneMappingSchema,
realtime: z
.object({
enabled: configBool.describe('Enable real-time HLS transcoding (alpha)'),
})
.meta({ id: 'SystemConfigFFmpegRealtimeDto' }),
})
.meta({ id: 'SystemConfigFFmpegDto' });
@@ -156,15 +151,8 @@ const SystemConfigMapSchema = z
})
.meta({ id: 'SystemConfigMapDto' });
export enum ReleaseChannel {
Stable = 'stable',
ReleaseCandidate = 'releaseCandidate',
}
const ReleaseChannelSchema = z.enum(ReleaseChannel).describe('Release channel').meta({ id: 'ReleaseChannel' });
const SystemConfigNewVersionCheckSchema = z
.object({ enabled: configBool.describe('Enabled'), channel: ReleaseChannelSchema })
.object({ enabled: configBool.describe('Enabled') })
.meta({ id: 'SystemConfigNewVersionCheckDto' });
const SystemConfigNightlyTasksSchema = z
+5 -4
View File
@@ -453,7 +453,11 @@ export enum VideoCodec {
export const VideoCodecSchema = z.enum(VideoCodec).describe('Target video codec').meta({ id: 'VideoCodec' });
export type VideoSegmentCodec = VideoCodec.Av1 | VideoCodec.Hevc | VideoCodec.H264;
export enum VideoSegmentCodec {
Av1 = 'av1',
Hevc = 'hevc',
H264 = 'h264',
}
export enum AudioCodec {
Mp3 = 'mp3',
@@ -823,8 +827,6 @@ export enum JobName {
LibrarySyncFiles = 'LibrarySyncFiles',
LibraryScanQueueAll = 'LibraryScanQueueAll',
HlsSessionCleanup = 'HlsSessionCleanup',
MemoryCleanup = 'MemoryCleanup',
MemoryGenerate = 'MemoryGenerate',
@@ -918,7 +920,6 @@ export enum DatabaseLock {
MaintenanceOperation = 621,
MemoryCreation = 777,
VersionCheck = 800,
HlsSessionCleanup = 850,
}
export enum MaintenanceAction {
+2 -255
View File
@@ -7,7 +7,6 @@ from
"video_stream_session"
where
"id" = $1
and "expiresAt" > $2
-- VideoStreamRepository.getVariant
select
@@ -28,13 +27,11 @@ where
-- VideoStreamRepository.getExpiredSessions
select
"video_stream_session"."id",
"asset"."ownerId"
"id"
from
"video_stream_session"
inner join "asset" on "asset"."id" = "video_stream_session"."assetId"
where
"video_stream_session"."expiresAt" <= $1
"expiresAt" <= $1
-- VideoStreamRepository.extendSession
update "video_stream_session"
@@ -47,253 +44,3 @@ where
delete from "video_stream_session"
where
"id" = $1
-- VideoStreamRepository.getForMainPlaylist
select
(
select
to_json(obj)
from
(
select
"asset_video"."index",
"asset_video"."codecName",
"asset_video"."profile",
"asset_video"."level",
"asset_video"."bitrate",
"asset_exif"."exifImageWidth" as "width",
"asset_exif"."exifImageHeight" as "height",
"asset_video"."pixelFormat",
"asset_video"."frameCount",
"asset_exif"."fps" as "frameRate",
"asset_video"."timeBase",
case
when "asset_exif"."orientation" = '6' then -90
when "asset_exif"."orientation" = '8' then 90
when "asset_exif"."orientation" = '3' then 180
else 0
end as "rotation",
"asset_video"."colorPrimaries",
"asset_video"."colorMatrix",
"asset_video"."colorTransfer",
"asset_video"."dvProfile",
"asset_video"."dvLevel",
"asset_video"."dvBlSignalCompatibilityId"
from
(
select
1
) as "dummy"
where
"asset_video"."assetId" is not null
) as obj
) as "videoStream",
(
select
to_json(obj)
from
(
select
"asset_keyframe"."pts" as "keyframePts",
"asset_keyframe"."accDuration" as "keyframeAccDuration",
"asset_keyframe"."ownDuration" as "keyframeOwnDuration",
"asset_keyframe"."totalDuration",
"asset_keyframe"."packetCount",
"asset_keyframe"."outputFrames"
from
(
select
1
) as "dummy"
where
"asset_keyframe"."assetId" is not null
) as obj
) as "packets"
from
"asset"
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
inner join "asset_video" on "asset"."id" = "asset_video"."assetId"
inner join "asset_keyframe" on "asset"."id" = "asset_keyframe"."assetId"
where
"asset"."id" = $1
-- VideoStreamRepository.getForMediaPlaylist
select
(
select
to_json(obj)
from
(
select
"asset_video"."index",
"asset_video"."codecName",
"asset_video"."profile",
"asset_video"."level",
"asset_video"."bitrate",
"asset_exif"."exifImageWidth" as "width",
"asset_exif"."exifImageHeight" as "height",
"asset_video"."pixelFormat",
"asset_video"."frameCount",
"asset_exif"."fps" as "frameRate",
"asset_video"."timeBase",
case
when "asset_exif"."orientation" = '6' then -90
when "asset_exif"."orientation" = '8' then 90
when "asset_exif"."orientation" = '3' then 180
else 0
end as "rotation",
"asset_video"."colorPrimaries",
"asset_video"."colorMatrix",
"asset_video"."colorTransfer",
"asset_video"."dvProfile",
"asset_video"."dvLevel",
"asset_video"."dvBlSignalCompatibilityId"
from
(
select
1
) as "dummy"
where
"asset_video"."assetId" is not null
) as obj
) as "videoStream",
(
select
to_json(obj)
from
(
select
"asset_keyframe"."pts" as "keyframePts",
"asset_keyframe"."accDuration" as "keyframeAccDuration",
"asset_keyframe"."ownDuration" as "keyframeOwnDuration",
"asset_keyframe"."totalDuration",
"asset_keyframe"."packetCount",
"asset_keyframe"."outputFrames"
from
(
select
1
) as "dummy"
where
"asset_keyframe"."assetId" is not null
) as obj
) as "packets"
from
"asset"
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
inner join "video_stream_session" on "asset"."id" = "video_stream_session"."assetId"
inner join "asset_video" on "asset"."id" = "asset_video"."assetId"
inner join "asset_keyframe" on "asset"."id" = "asset_keyframe"."assetId"
where
"asset"."id" = $1
and "video_stream_session"."id" = $2
and "video_stream_session"."expiresAt" > $3
-- VideoStreamRepository.getForTranscoding
select
"asset"."originalPath",
(
select
to_json(obj)
from
(
select
"asset_audio"."index",
"asset_audio"."codecName",
"asset_audio"."profile",
"asset_audio"."bitrate"
from
(
select
1
) as "dummy"
where
"asset_audio"."assetId" is not null
) as obj
) as "audioStream",
(
select
to_json(obj)
from
(
select
"asset_video"."index",
"asset_video"."codecName",
"asset_video"."profile",
"asset_video"."level",
"asset_video"."bitrate",
"asset_exif"."exifImageWidth" as "width",
"asset_exif"."exifImageHeight" as "height",
"asset_video"."pixelFormat",
"asset_video"."frameCount",
"asset_exif"."fps" as "frameRate",
"asset_video"."timeBase",
case
when "asset_exif"."orientation" = '6' then -90
when "asset_exif"."orientation" = '8' then 90
when "asset_exif"."orientation" = '3' then 180
else 0
end as "rotation",
"asset_video"."colorPrimaries",
"asset_video"."colorMatrix",
"asset_video"."colorTransfer",
"asset_video"."dvProfile",
"asset_video"."dvLevel",
"asset_video"."dvBlSignalCompatibilityId"
from
(
select
1
) as "dummy"
where
"asset_video"."assetId" is not null
) as obj
) as "videoStream",
(
select
to_json(obj)
from
(
select
"asset_video"."formatName",
"asset_video"."formatLongName",
"asset"."duration",
"asset_video"."bitrate"
from
(
select
1
) as "dummy"
where
"asset_video"."assetId" is not null
) as obj
) as "format",
(
select
to_json(obj)
from
(
select
"asset_keyframe"."pts" as "keyframePts",
"asset_keyframe"."accDuration" as "keyframeAccDuration",
"asset_keyframe"."ownDuration" as "keyframeOwnDuration",
"asset_keyframe"."totalDuration",
"asset_keyframe"."packetCount",
"asset_keyframe"."outputFrames"
from
(
select
1
) as "dummy"
where
"asset_keyframe"."assetId" is not null
) as obj
) as "packets"
from
"asset"
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
left join "asset_audio" on "asset"."id" = "asset_audio"."assetId"
inner join "asset_video" on "asset"."id" = "asset_video"."assetId"
inner join "asset_keyframe" on "asset"."id" = "asset_keyframe"."assetId"
where
"asset"."id" = $1
@@ -92,14 +92,6 @@ type EventMap = {
AuthChangePassword: [{ userId: string; currentSessionId?: string; invalidateSessions?: boolean }];
// hls streaming events
HlsSegmentRequest: [{ sessionId: string; assetId: string; variantIndex: number; segmentIndex: number }];
HlsSegmentResult: [{ sessionId: string; variantIndex: number; segmentIndex: number; error?: string }];
HlsHeartbeat: [{ sessionId: string; variantIndex?: number; segmentIndex?: number }];
HlsSessionRequest: [{ sessionId: string; assetId: string; ownerId: string }];
HlsSessionResult: [{ sessionId: string; error?: string }];
HlsSessionEnd: [{ sessionId: string }];
// websocket events
WebsocketConnect: [{ userId: string }];
};
+4 -29
View File
@@ -490,43 +490,18 @@ export class MediaRepository {
return this.parseInt(b.bit_rate) - this.parseInt(a.bit_rate);
}
/* Ported from https://code.ffmpeg.org/FFmpeg/FFmpeg/src/commit/5c44245878e235ae64fe87fb9877644856d33d1d/fftools/ffmpeg_filter.c
* SPDX-License-Identifier: LGPL-2.1-or-later
* Copyright (c) FFmpeg authors and contributors https://ffmpeg.org/
* Modifications: TS port operating on probe-derived packet metadata rather than decoded AVFrames. */
private cfrOutputFrames(packets: { pts: number; duration: number }[], slotsPerTick: number) {
// Packets may be out of PTS order due to B-frames
packets.sort((a, b) => a.pts - b.pts);
const firstPts = packets[0].pts;
let outputFrames = 0;
let nextPts = 0;
const history = [0, 0, 0];
for (const pkt of packets) {
const syncIpts = (pkt.pts - firstPts) * slotsPerTick;
const duration = pkt.duration * slotsPerTick;
let delta0 = syncIpts - nextPts;
const delta = delta0 + duration;
if (delta0 < 0 && delta > 0) {
delta0 = 0;
}
let nb = 1;
let nbPrev = 0;
if (delta < -1.1) {
nb = 0;
} else if (delta > 1.1) {
nb = Math.round(delta);
if (delta0 > 1.1) {
nbPrev = Math.round(delta0 - 0.6);
}
}
const delta = (pkt.pts - firstPts) * slotsPerTick - nextPts + pkt.duration * slotsPerTick;
const nb = delta < -1.1 ? 0 : delta > 1.1 ? Math.round(delta) : 1;
outputFrames += nb;
nextPts += nb;
history[2] = history[1];
history[1] = history[0];
history[0] = nbPrev;
}
const median = history.sort((a, b) => a - b)[1];
return outputFrames + median;
return outputFrames;
}
}
@@ -1,10 +1,12 @@
import { Injectable } from '@nestjs/common';
import { fork, spawn, SpawnOptionsWithoutStdio } from 'node:child_process';
import { ChildProcessWithoutNullStreams, fork, spawn, SpawnOptionsWithoutStdio } from 'node:child_process';
import { Duplex } from 'node:stream';
@Injectable()
export class ProcessRepository {
spawn = spawn;
spawn(command: string, args?: readonly string[], options?: SpawnOptionsWithoutStdio): ChildProcessWithoutNullStreams {
return spawn(command, args, options);
}
spawnDuplexStream(command: string, args?: readonly string[], options?: SpawnOptionsWithoutStdio): Duplex {
let stdinClosed = false;
@@ -4,7 +4,6 @@ import { exec as execCallback } from 'node:child_process';
import { readFile } from 'node:fs/promises';
import { promisify } from 'node:util';
import sharp from 'sharp';
import { ReleaseChannel } from 'src/dtos/system-config.dto';
import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
@@ -65,12 +64,10 @@ export class ServerInfoRepository {
this.logger.setContext(ServerInfoRepository.name);
}
async getLatestRelease(channel: ReleaseChannel): Promise<VersionResponse> {
async getLatestRelease(): Promise<VersionResponse> {
try {
const { versionCheck } = this.configRepository.getEnv();
const url = new URL(versionCheck.url);
url.searchParams.append('channel', channel);
const response = await fetch(url);
const response = await fetch(versionCheck.url);
if (!response.ok) {
throw new Error(`Version check request failed with status ${response.status}: ${await response.text()}`);
@@ -10,7 +10,6 @@ import {
existsSync,
mkdirSync,
ReadOptionsWithBuffer,
watch,
} from 'node:fs';
import fs from 'node:fs/promises';
import path from 'node:path';
@@ -278,8 +277,6 @@ export class StorageRepository {
return () => watcher.close();
}
watchDir = watch; // Native fs.watch without chokidar overhead
private asGlob(pathToCrawl: string): string {
const escapedPath = escapePath(pathToCrawl).replaceAll('"', '["]').replaceAll("'", "[']").replaceAll('`', '[`]');
const extensions = `*{${mimeTypes.getSupportedFileExtensions().join(',')}}`;
@@ -8,7 +8,6 @@ import {
VideoStreamSessionTable,
VideoStreamVariantTable,
} from 'src/schema/tables/video-stream.table';
import { withAudioStream, withVideoFormat, withVideoPackets, withVideoStream } from 'src/utils/database';
@Injectable()
export class VideoStreamRepository {
@@ -28,12 +27,7 @@ export class VideoStreamRepository {
@GenerateSql({ params: [DummyValue.UUID] })
getSession(id: string) {
return this.db
.selectFrom('video_stream_session')
.selectAll()
.where('id', '=', id)
.where('expiresAt', '>', new Date())
.executeTakeFirst();
return this.db.selectFrom('video_stream_session').selectAll().where('id', '=', id).executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] })
@@ -53,12 +47,7 @@ export class VideoStreamRepository {
@GenerateSql()
getExpiredSessions() {
return this.db
.selectFrom('video_stream_session')
.innerJoin('asset', 'asset.id', 'video_stream_session.assetId')
.select(['video_stream_session.id', 'asset.ownerId'])
.where('video_stream_session.expiresAt', '<=', new Date())
.execute();
return this.db.selectFrom('video_stream_session').select(['id']).where('expiresAt', '<=', new Date()).execute();
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.DATE] })
@@ -70,50 +59,4 @@ export class VideoStreamRepository {
async deleteSession(id: string) {
await this.db.deleteFrom('video_stream_session').where('id', '=', id).execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
async getForMainPlaylist(id: string) {
return this.db
.selectFrom('asset')
.innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
.where('asset.id', '=', id)
.innerJoin('asset_video', 'asset.id', 'asset_video.assetId')
.innerJoin('asset_keyframe', 'asset.id', 'asset_keyframe.assetId')
.select((eb) => withVideoStream(eb).$notNull().as('videoStream'))
.select((eb) => withVideoPackets(eb).$notNull().as('packets'))
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
async getForMediaPlaylist(id: string, sessionId: string) {
return this.db
.selectFrom('asset')
.innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
.innerJoin('video_stream_session', 'asset.id', 'video_stream_session.assetId')
.where('asset.id', '=', id)
.where('video_stream_session.id', '=', sessionId)
.where('video_stream_session.expiresAt', '>', new Date())
.innerJoin('asset_video', 'asset.id', 'asset_video.assetId')
.innerJoin('asset_keyframe', 'asset.id', 'asset_keyframe.assetId')
.select((eb) => withVideoStream(eb).$notNull().as('videoStream'))
.select((eb) => withVideoPackets(eb).$notNull().as('packets'))
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] })
async getForTranscoding(id: string) {
return this.db
.selectFrom('asset')
.innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
.where('asset.id', '=', id)
.leftJoin('asset_audio', 'asset.id', 'asset_audio.assetId')
.innerJoin('asset_video', 'asset.id', 'asset_video.assetId')
.innerJoin('asset_keyframe', 'asset.id', 'asset_keyframe.assetId')
.select('asset.originalPath')
.select((eb) => withAudioStream(eb).as('audioStream'))
.select((eb) => withVideoStream(eb).$notNull().as('videoStream'))
.select((eb) => withVideoFormat(eb).$notNull().as('format'))
.select((eb) => withVideoPackets(eb).$notNull().as('packets'))
.executeTakeFirst();
}
}
@@ -10,22 +10,13 @@ import { Server, Socket } from 'socket.io';
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { NotificationDto } from 'src/dtos/notification.dto';
import { ReleaseEventV1, ServerVersionResponseDto } from 'src/dtos/server.dto';
import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto';
import { SyncAssetEditV1, SyncAssetExifV1, SyncAssetV2 } from 'src/dtos/sync.dto';
import { AppRestartEvent, ArgsOf, EventRepository } from 'src/repositories/event.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { handlePromiseError } from 'src/utils/misc';
export const serverEvents = [
'ConfigUpdate',
'AppRestart',
'HlsSegmentRequest',
'HlsSegmentResult',
'HlsHeartbeat',
'HlsSessionRequest',
'HlsSessionResult',
'HlsSessionEnd',
] as const;
export const serverEvents = ['ConfigUpdate', 'AppRestart'] as const;
export type ServerEvents = (typeof serverEvents)[number];
export interface ClientEventMap {
@@ -40,7 +31,7 @@ export interface ClientEventMap {
on_person_thumbnail: [string];
on_server_version: [ServerVersionResponseDto];
on_config_update: [];
on_new_release: [ReleaseEventV1];
on_new_release: [ReleaseNotification];
on_notification: [NotificationDto];
on_session_delete: [string];
+9 -2
View File
@@ -1,5 +1,12 @@
import { registerEnum } from '@immich/sql-tools';
import { AlbumUserRole, AssetStatus, AssetVisibility, ChecksumAlgorithm, SourceType, VideoCodec } from 'src/enum';
import {
AlbumUserRole,
AssetStatus,
AssetVisibility,
ChecksumAlgorithm,
SourceType,
VideoSegmentCodec,
} from 'src/enum';
export const album_user_role_enum = registerEnum({
name: 'album_user_role_enum',
@@ -28,5 +35,5 @@ export const asset_checksum_algorithm_enum = registerEnum({
export const video_stream_variant_codec_enum = registerEnum({
name: 'video_stream_variant_codec_enum',
values: [VideoCodec.Av1, VideoCodec.Hevc, VideoCodec.H264],
values: Object.values(VideoSegmentCodec),
});
-327
View File
@@ -1,327 +0,0 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { TranscodeHardwareAcceleration } 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';
import { newTestService, ServiceMocks } from 'test/utils';
// EXTINF values come from FFmpeg's playlist to enforce an exact match
const eiffelExpectedMediaPlaylist = `#EXTM3U
#EXT-X-VERSION:7
#EXT-X-TARGETDURATION:2
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-PLAYLIST-TYPE:VOD
#EXT-X-MAP:URI="init.mp4"
#EXTINF:2.007222,
seg_0.m4s
#EXTINF:2.007222,
seg_1.m4s
#EXTINF:2.007222,
seg_2.m4s
#EXTINF:2.007222,
seg_3.m4s
#EXTINF:2.007222,
seg_4.m4s
#EXTINF:2.007222,
seg_5.m4s
#EXTINF:2.007222,
seg_6.m4s
#EXTINF:2.007222,
seg_7.m4s
#EXTINF:2.007222,
seg_8.m4s
#EXTINF:2.007222,
seg_9.m4s
#EXTINF:2.007222,
seg_10.m4s
#EXTINF:0.281011,
seg_11.m4s
#EXT-X-ENDLIST
`;
const waterfallExpectedMediaPlaylist = `#EXTM3U
#EXT-X-VERSION:7
#EXT-X-TARGETDURATION:2
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-PLAYLIST-TYPE:VOD
#EXT-X-MAP:URI="init.mp4"
#EXTINF:2.011405,
seg_0.m4s
#EXTINF:2.011405,
seg_1.m4s
#EXTINF:2.011405,
seg_2.m4s
#EXTINF:2.011405,
seg_3.m4s
#EXTINF:2.011405,
seg_4.m4s
#EXTINF:0.301711,
seg_5.m4s
#EXT-X-ENDLIST
`;
const trainExpectedMediaPlaylist = `#EXTM3U
#EXT-X-VERSION:7
#EXT-X-TARGETDURATION:2
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-PLAYLIST-TYPE:VOD
#EXT-X-MAP:URI="init.mp4"
#EXTINF:2.000000,
seg_0.m4s
#EXTINF:2.000000,
seg_1.m4s
#EXTINF:2.000000,
seg_2.m4s
#EXTINF:2.000000,
seg_3.m4s
#EXTINF:2.000000,
seg_4.m4s
#EXTINF:2.000000,
seg_5.m4s
#EXTINF:2.000000,
seg_6.m4s
#EXTINF:2.000000,
seg_7.m4s
#EXTINF:2.000000,
seg_8.m4s
#EXTINF:2.000000,
seg_9.m4s
#EXTINF:1.733333,
seg_10.m4s
#EXT-X-ENDLIST
`;
const sessionId = '00000000-0000-0000-0000-000000000000';
const eiffelExpectedMasterDisabled = `#EXTM3U
#EXT-X-VERSION:7
#EXT-X-STREAM-INF:BANDWIDTH=1000000,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
${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
${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
${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
${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
${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
${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
${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
${sessionId}/8/playlist.m3u8
`;
const eiffelExpectedMasterRkmpp = `#EXTM3U
#EXT-X-VERSION:7
#EXT-X-STREAM-INF:BANDWIDTH=1200000,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
${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
${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
${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
${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
${sessionId}/8/playlist.m3u8
`;
const waterfallExpectedMasterDisabled = `#EXTM3U
#EXT-X-VERSION:7
#EXT-X-STREAM-INF:BANDWIDTH=1000000,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
${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
${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
${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
${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
${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
${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
${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
${sessionId}/8/playlist.m3u8
`;
describe(HlsService.name, () => {
let sut: HlsService;
let mocks: ServiceMocks;
beforeEach(() => {
({ sut, mocks } = newTestService(HlsService));
});
describe('getMainPlaylist', () => {
const auth = factory.auth();
const assetId = 'asset-1';
const setup = (asset: typeof eiffelTower | typeof waterfall, accel: TranscodeHardwareAcceleration) => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { realtime: { enabled: true }, accel } });
mocks.videoStream.getForMainPlaylist.mockResolvedValue(asset);
mocks.crypto.randomUUID.mockReturnValue(sessionId);
mocks.websocket.serverSend.mockImplementation((event, ...rest) => {
if (event === 'HlsSessionRequest') {
const { sessionId: id } = rest[0] as { sessionId: string };
queueMicrotask(() => sut.onSessionResult({ sessionId: id }));
}
});
};
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('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('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('throws BadRequestException when realtime transcoding is disabled', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { realtime: { enabled: false } } });
await expect(sut.getMainPlaylist(auth, assetId)).rejects.toBeInstanceOf(BadRequestException);
});
it('throws NotFoundException when asset is not yet ready for streaming', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { realtime: { enabled: true } } });
await expect(sut.getMainPlaylist(auth, assetId)).rejects.toBeInstanceOf(NotFoundException);
});
});
describe('getMediaPlaylist', () => {
const auth = factory.auth();
const assetId = 'asset-1';
const fixtures = [
{ data: eiffelTower, playlist: eiffelExpectedMediaPlaylist },
{ data: waterfall, playlist: waterfallExpectedMediaPlaylist },
{ data: train, playlist: trainExpectedMediaPlaylist },
];
it.each(fixtures)('matches FFmpeg for $data.originalPath', async ({ data, playlist }) => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
mocks.videoStream.getForMediaPlaylist.mockResolvedValue(data);
await expect(sut.getMediaPlaylist(auth, assetId, sessionId)).resolves.toBe(playlist);
});
it('throws NotFoundException when the session/asset cannot be loaded', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
await expect(sut.getMediaPlaylist(auth, assetId, sessionId)).rejects.toBeInstanceOf(NotFoundException);
});
});
describe('getSegment', () => {
const auth = factory.auth();
const assetId = 'asset-1';
const variantIndex = 0;
beforeEach(() => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
mocks.videoStream.getSession.mockResolvedValue({ id: sessionId, assetId } as never);
mocks.storage.checkFileExists.mockResolvedValue(true);
});
it('emits HlsHeartbeat with segmentIndex 0 for the first init.mp4 request', async () => {
await sut.getSegment(auth, assetId, sessionId, variantIndex, 'init.mp4');
expect(mocks.websocket.serverSend).toHaveBeenCalledWith('HlsHeartbeat', {
sessionId,
variantIndex,
segmentIndex: 0,
});
});
it('emits HlsHeartbeat with the parsed segment number for seg_K.m4s', async () => {
await sut.getSegment(auth, assetId, sessionId, variantIndex, 'seg_5.m4s');
expect(mocks.websocket.serverSend).toHaveBeenCalledWith('HlsHeartbeat', {
sessionId,
variantIndex,
segmentIndex: 5,
});
});
it('returns lastRequested + 1 for init.mp4 after a segment has been served', async () => {
await sut.getSegment(auth, assetId, sessionId, variantIndex, 'seg_5.m4s');
mocks.websocket.serverSend.mockClear();
await sut.getSegment(auth, assetId, sessionId, variantIndex, 'init.mp4');
expect(mocks.websocket.serverSend).toHaveBeenCalledWith('HlsHeartbeat', {
sessionId,
variantIndex,
segmentIndex: 6,
});
});
it('updates lastRequested on a backward-seek segment request', async () => {
await sut.getSegment(auth, assetId, sessionId, variantIndex, 'seg_5.m4s');
await sut.getSegment(auth, assetId, sessionId, variantIndex, 'seg_3.m4s');
mocks.websocket.serverSend.mockClear();
await sut.getSegment(auth, assetId, sessionId, variantIndex, 'init.mp4');
expect(mocks.websocket.serverSend).toHaveBeenCalledWith('HlsHeartbeat', {
sessionId,
variantIndex,
segmentIndex: 4,
});
});
it('tracks segment state per session independently', async () => {
await sut.getSegment(auth, assetId, 'session-a', variantIndex, 'seg_5.m4s');
await sut.getSegment(auth, assetId, 'session-b', variantIndex, 'seg_2.m4s');
mocks.websocket.serverSend.mockClear();
await sut.getSegment(auth, assetId, 'session-a', variantIndex, 'init.mp4');
await sut.getSegment(auth, assetId, 'session-b', variantIndex, 'init.mp4');
expect(mocks.websocket.serverSend).toHaveBeenCalledWith('HlsHeartbeat', {
sessionId: 'session-a',
variantIndex,
segmentIndex: 6,
});
expect(mocks.websocket.serverSend).toHaveBeenCalledWith('HlsHeartbeat', {
sessionId: 'session-b',
variantIndex,
segmentIndex: 3,
});
});
it('rejects pending waiters for the previous variant on variant change', async () => {
mocks.storage.checkFileExists.mockResolvedValueOnce(false);
const pending = sut.getSegment(auth, assetId, sessionId, 0, 'seg_1.m4s');
await new Promise((resolve) => setImmediate(resolve));
await sut.getSegment(auth, assetId, sessionId, 1, 'seg_1.m4s');
await expect(pending).rejects.toThrow('Variant changed');
});
it('throws NotFoundException when the session does not exist', async () => {
mocks.videoStream.getSession.mockReset();
await expect(sut.getSegment(auth, assetId, sessionId, variantIndex, 'init.mp4')).rejects.toBeInstanceOf(
NotFoundException,
);
});
});
describe('endSession', () => {
it('emits HlsSessionEnd', async () => {
const auth = factory.auth();
const assetId = 'asset-1';
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
await sut.endSession(auth, assetId, sessionId);
expect(mocks.websocket.serverSend).toHaveBeenCalledWith('HlsSessionEnd', { sessionId });
});
});
});
-198
View File
@@ -1,198 +0,0 @@
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 { StorageCore } from 'src/cores/storage.core';
import { OnEvent } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
import { CacheControl, ImmichWorker, Permission } from 'src/enum';
import { ArgOf } from 'src/repositories/event.repository';
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';
type AssetWithStreamInfo = { videoStream: VideoStreamInfo & { timeBase: number }; packets: VideoPacketInfo };
type ApiSession = { lastRequestedSegment: number | null; lastVariantIndex: number | null };
@Injectable()
export class HlsService extends BaseService {
private pendingSegments = new PendingEvents<'HlsSegmentResult'>({ timeoutMs: 15_000 });
private pendingSessions = new PendingEvents<'HlsSessionResult'>({ timeoutMs: 5000 });
private sessions = new Map<string, ApiSession>();
@OnEvent({ name: 'HlsSessionResult', server: true, workers: [ImmichWorker.Api] })
onSessionResult(event: ArgOf<'HlsSessionResult'>) {
this.pendingSessions.complete(event.sessionId, event);
if (event.error) {
this.sessions.delete(event.sessionId);
this.pendingSegments.rejectByPrefix(`${event.sessionId}:`, event.error);
}
}
@OnEvent({ name: 'HlsSessionEnd', server: true, workers: [ImmichWorker.Api] })
onSessionEnd({ sessionId }: ArgOf<'HlsSessionEnd'>) {
this.sessions.delete(sessionId);
this.pendingSegments.rejectByPrefix(`${sessionId}:`, 'Session ended');
}
@OnEvent({ name: 'HlsSegmentResult', server: true, workers: [ImmichWorker.Api] })
onSegmentResult(event: ArgOf<'HlsSegmentResult'>) {
this.pendingSegments.complete(this.getSegmentKey(event), event);
}
async getMainPlaylist(auth: AuthDto, assetId: string) {
await this.requireAccess({ auth, permission: Permission.AssetView, ids: [assetId] });
const { ffmpeg } = await this.getConfig({ withCache: true });
if (!ffmpeg.realtime.enabled) {
throw new BadRequestException('Real-time transcoding is not enabled');
}
const asset = await this.videoStreamRepository.getForMainPlaylist(assetId);
if (!asset) {
throw new NotFoundException('Asset is not yet ready for streaming');
}
// Sharing the sessionId allows only one microservices worker to successfully insert to the session table.
// The microservices worker that creates a session owns the transcoding lifecycle for it.
const sessionId = this.cryptoRepository.randomUUID();
this.websocketRepository.serverSend('HlsSessionRequest', { sessionId, assetId, ownerId: auth.user.id });
await this.pendingSessions.wait(sessionId);
this.trackSession(sessionId);
return this.generateMainPlaylist(sessionId, ffmpeg, asset);
}
async getMediaPlaylist(auth: AuthDto, assetId: string, sessionId: string) {
await this.requireAccess({ auth, permission: Permission.AssetView, ids: [assetId] });
const asset = await this.videoStreamRepository.getForMediaPlaylist(assetId, sessionId);
if (!asset) {
throw new NotFoundException('Asset not found or not yet ready for streaming');
}
return this.generateMediaPlaylist(asset);
}
async getSegment(auth: AuthDto, assetId: string, sessionId: string, variantIndex: number, filename: string) {
await this.requireAccess({ auth, permission: Permission.AssetView, ids: [assetId] });
const session = await this.videoStreamRepository.getSession(sessionId);
if (!session) {
throw new NotFoundException('Session not found');
}
const variantDir = StorageCore.getHlsVariantFolder({ ownerId: auth.user.id, sessionId, variantIndex });
const path = join(variantDir, filename);
const response = new ImmichFileResponse({
path,
contentType: 'video/mp4',
cacheControl: CacheControl.PrivateWithCache,
});
const apiSession = this.trackSession(sessionId, variantIndex);
const segmentIndex = this.getSegmentIndex(apiSession, filename);
this.websocketRepository.serverSend('HlsHeartbeat', { sessionId, variantIndex, segmentIndex });
if (await this.storageRepository.checkFileExists(path, constants.R_OK)) {
return response;
}
this.websocketRepository.serverSend('HlsSegmentRequest', { sessionId, assetId, variantIndex, segmentIndex });
await this.pendingSegments.wait(this.getSegmentKey({ sessionId, variantIndex, segmentIndex }));
return response;
}
async endSession(auth: AuthDto, assetId: string, sessionId: string): Promise<void> {
await this.requireAccess({ auth, permission: Permission.AssetView, ids: [assetId] });
this.websocketRepository.serverSend('HlsSessionEnd', { sessionId });
}
private generateMainPlaylist(sessionId: string, ffmpeg: SystemConfigFFmpegDto, asset: AssetWithStreamInfo) {
const fps = ((asset.packets.packetCount * asset.videoStream.timeBase) / asset.packets.totalDuration).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}`];
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)) {
continue;
}
const { width, height } = getOutputSize(asset.videoStream, resolution);
lines.push(
`#EXT-X-STREAM-INF:BANDWIDTH=${bitrate},RESOLUTION=${width}x${height},CODECS="${codecString},mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=${fps}`,
`${sessionId}/${i}/playlist.m3u8`,
);
}
lines.push('');
if (lines.length === 3) {
throw new NotFoundException('No supported variants for this video');
}
return lines.join('\n');
}
private generateMediaPlaylist({ videoStream, packets }: AssetWithStreamInfo) {
const fps = (packets.packetCount * videoStream.timeBase) / packets.totalDuration;
const framesPerSegment = Math.ceil(HLS_SEGMENT_DURATION * fps);
const fullSegmentDuration = framesPerSegment / fps;
const segmentCount = Math.ceil(packets.outputFrames / framesPerSegment);
const lastSegmentFrames = packets.outputFrames - framesPerSegment * (segmentCount - 1);
const lastSegmentDuration = lastSegmentFrames / fps;
const lines = [
'#EXTM3U',
`#EXT-X-VERSION:${HLS_VERSION}`,
`#EXT-X-TARGETDURATION:${HLS_SEGMENT_DURATION}`,
'#EXT-X-MEDIA-SEQUENCE:0',
'#EXT-X-PLAYLIST-TYPE:VOD',
'#EXT-X-MAP:URI="init.mp4"',
];
for (let i = 0; i < segmentCount - 1; i++) {
lines.push(`#EXTINF:${fullSegmentDuration.toFixed(6)},`, `seg_${i}.m4s`);
}
lines.push(`#EXTINF:${lastSegmentDuration.toFixed(6)},`, `seg_${segmentCount - 1}.m4s`, '#EXT-X-ENDLIST', '');
return lines.join('\n');
}
private getSegmentKey({ sessionId, variantIndex, segmentIndex }: ArgOf<'HlsSegmentResult'>) {
return `${sessionId}:${variantIndex}:${segmentIndex}`;
}
private getSegmentIndex(session: ApiSession, filename: string) {
if (filename.endsWith('.mp4')) {
return (session.lastRequestedSegment ?? -1) + 1;
}
const segmentIndex = Number.parseInt(HLS_SEGMENT_FILENAME_REGEX.exec(filename)![1]);
session.lastRequestedSegment = segmentIndex;
return segmentIndex;
}
private trackSession(id: string, variantIndex: number | null = null) {
const session = this.sessions.get(id);
if (!session) {
const newSession = { lastRequestedSegment: null, lastVariantIndex: variantIndex };
this.sessions.set(id, newSession);
return newSession;
}
if (session.lastVariantIndex !== null && session.lastVariantIndex !== variantIndex) {
this.pendingSegments.rejectByPrefix(`${id}:${session.lastVariantIndex}:`, 'Variant changed');
}
session.lastVariantIndex = variantIndex;
return session;
}
}
-4
View File
@@ -11,7 +11,6 @@ import { DatabaseBackupService } from 'src/services/database-backup.service';
import { DatabaseService } from 'src/services/database.service';
import { DownloadService } from 'src/services/download.service';
import { DuplicateService } from 'src/services/duplicate.service';
import { HlsService } from 'src/services/hls.service';
import { JobService } from 'src/services/job.service';
import { LibraryService } from 'src/services/library.service';
import { MaintenanceService } from 'src/services/maintenance.service';
@@ -40,7 +39,6 @@ import { SystemMetadataService } from 'src/services/system-metadata.service';
import { TagService } from 'src/services/tag.service';
import { TelemetryService } from 'src/services/telemetry.service';
import { TimelineService } from 'src/services/timeline.service';
import { TranscodingService } from 'src/services/transcoding.service';
import { TrashService } from 'src/services/trash.service';
import { UserAdminService } from 'src/services/user-admin.service';
import { UserService } from 'src/services/user.service';
@@ -63,7 +61,6 @@ export const services = [
DatabaseService,
DownloadService,
DuplicateService,
HlsService,
JobService,
LibraryService,
MaintenanceService,
@@ -92,7 +89,6 @@ export const services = [
TagService,
TelemetryService,
TimelineService,
TranscodingService,
TrashService,
UserAdminService,
UserService,
@@ -41,7 +41,6 @@ describe(QueueService.name, () => {
{ name: JobName.PersonCleanup },
{ name: JobName.MemoryCleanup },
{ name: JobName.SessionCleanup },
{ name: JobName.HlsSessionCleanup },
{ name: JobName.AuditTableCleanup },
{ name: JobName.MemoryGenerate },
{ name: JobName.UserSyncUsage },
-1
View File
@@ -269,7 +269,6 @@ export class QueueService extends BaseService {
{ name: JobName.PersonCleanup },
{ name: JobName.MemoryCleanup },
{ name: JobName.SessionCleanup },
{ name: JobName.HlsSessionCleanup },
{ name: JobName.AuditTableCleanup },
);
}
@@ -148,7 +148,6 @@ describe(ServerService.name, () => {
configFile: false,
trash: true,
email: false,
realtimeTranscoding: false,
});
expect(mocks.systemMetadata.get).toHaveBeenCalled();
});
+1 -2
View File
@@ -86,7 +86,7 @@ export class ServerService extends BaseService {
}
async getFeatures(): Promise<ServerFeaturesDto> {
const { reverseGeocoding, metadata, map, machineLearning, trash, oauth, passwordLogin, notifications, ffmpeg } =
const { reverseGeocoding, metadata, map, machineLearning, trash, oauth, passwordLogin, notifications } =
await this.getConfig({ withCache: false });
const { configFile } = this.configRepository.getEnv();
@@ -106,7 +106,6 @@ export class ServerService extends BaseService {
passwordLogin: passwordLogin.enabled,
configFile: !!configFile,
email: notifications.smtp.enabled,
realtimeTranscoding: ffmpeg.realtime.enabled,
};
}

Some files were not shown because too many files have changed in this diff Show More