mirror of
https://github.com/immich-app/immich.git
synced 2026-06-03 05:15:20 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5fefd13ac8 | |||
| f1f8142409 |
@@ -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 []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
+66
-34
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Generated
+58
-42
@@ -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)
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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}");
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Generated
-8
@@ -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)
|
||||
|
||||
Generated
-4
@@ -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';
|
||||
|
||||
Generated
-314
@@ -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.
|
||||
|
||||
Generated
-8
@@ -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':
|
||||
|
||||
Generated
-6
@@ -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();
|
||||
}
|
||||
|
||||
Generated
-3
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+20
-57
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {}
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 }];
|
||||
};
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user