mirror of
https://github.com/immich-app/immich.git
synced 2026-06-22 06:42:27 -07:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 39fe991451 |
+14
-14
@@ -5,38 +5,38 @@ version = "3.11.15"
|
||||
backend = "core:python"
|
||||
|
||||
[tools.python."platforms.linux-arm64"]
|
||||
checksum = "sha256:cbce0660e88cd9c56be7aaf2a2df92bea51f359388a521838b6b01817d728df0"
|
||||
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260610/cpython-3.11.15+20260610-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz"
|
||||
checksum = "sha256:243f794278eff6adba96ed3677ec6877175df84c25f140e17f09f9be82d0f12a"
|
||||
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260510/cpython-3.11.15+20260510-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.python."platforms.linux-arm64-musl"]
|
||||
checksum = "sha256:a55ea44225ee3741d4157c383f3d5c3e8eee5f9665e2ea069233486b4275d928"
|
||||
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260610/cpython-3.11.15+20260610-aarch64-unknown-linux-musl-install_only_stripped.tar.gz"
|
||||
checksum = "sha256:52b4c52094ff8b383a45c694acf4c5c0e883152be6d5229a35a8186ce907c6eb"
|
||||
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260510/cpython-3.11.15+20260510-aarch64-unknown-linux-musl-install_only_stripped.tar.gz"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.python."platforms.linux-x64"]
|
||||
checksum = "sha256:67a5b22f796e96f4d7fa628f95866d5fd1d524d0588f74e4601facd82b66792b"
|
||||
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260610/cpython-3.11.15+20260610-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz"
|
||||
checksum = "sha256:171dffd8c0f66e8a0725364a7428015b22fc18dd298b24f541392e17dd0e561f"
|
||||
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260510/cpython-3.11.15+20260510-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.python."platforms.linux-x64-musl"]
|
||||
checksum = "sha256:5a8544aa4303da3ca4b7505c98dd8453b671157039d25cd551e55abea0f83a60"
|
||||
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260610/cpython-3.11.15+20260610-x86_64-unknown-linux-musl-install_only_stripped.tar.gz"
|
||||
checksum = "sha256:2ac90fef8917ebd14826a6d667593a06cf0ae5f745ba9b1147dc086dd35f5284"
|
||||
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260510/cpython-3.11.15+20260510-x86_64-unknown-linux-musl-install_only_stripped.tar.gz"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.python."platforms.macos-arm64"]
|
||||
checksum = "sha256:8c56f1f59142e0f9f8861ad897bdfd97fd84403afa7b3d8b0f33b208ec471355"
|
||||
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260610/cpython-3.11.15+20260610-aarch64-apple-darwin-install_only_stripped.tar.gz"
|
||||
checksum = "sha256:fdfc363b538662eb7441a14e06f72c4a992c56af7f401f5730ea5081f8f8ad6e"
|
||||
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260510/cpython-3.11.15+20260510-aarch64-apple-darwin-install_only_stripped.tar.gz"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.python."platforms.macos-x64"]
|
||||
checksum = "sha256:8cd3878c656ba1698314cbcb65f78df4c37b7c8eabff958558115c6db11adb3d"
|
||||
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260610/cpython-3.11.15+20260610-x86_64-apple-darwin-install_only_stripped.tar.gz"
|
||||
checksum = "sha256:5f1eb247cbca2c0ad5ccbf6d299a4f54b31b5c63b492d74c3531dc4344a42f88"
|
||||
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260510/cpython-3.11.15+20260510-x86_64-apple-darwin-install_only_stripped.tar.gz"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.python."platforms.windows-x64"]
|
||||
checksum = "sha256:f081a733b4e7ba0e5e5e12d533b3c795dbef3ecbebf92f0b4202e5329bf7c8ab"
|
||||
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260610/cpython-3.11.15+20260610-x86_64-pc-windows-msvc-install_only_stripped.tar.gz"
|
||||
checksum = "sha256:756d7f148498b8822f6aedf44a020613576f09983161f346ad36dcef6238cdc3"
|
||||
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260510/cpython-3.11.15+20260510-x86_64-pc-windows-msvc-install_only_stripped.tar.gz"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[[tools.uv]]
|
||||
|
||||
@@ -23,6 +23,6 @@ class ImmichApp : Application() {
|
||||
// as the previous start might have been killed without unlocking.
|
||||
if (BackgroundEngineLock.connectEngines > 0) return@postDelayed
|
||||
BackgroundWorkerApiImpl.enqueueBackgroundWorker(this)
|
||||
}, 5000)
|
||||
}, 15000)
|
||||
}
|
||||
}
|
||||
|
||||
+20
-4
@@ -15,6 +15,7 @@ import androidx.work.ListenableWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import app.alextran.immich.MainActivity
|
||||
import app.alextran.immich.R
|
||||
import com.google.common.util.concurrent.Futures
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import com.google.common.util.concurrent.SettableFuture
|
||||
import io.flutter.FlutterInjector
|
||||
@@ -61,6 +62,11 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
|
||||
}
|
||||
|
||||
override fun startWork(): ListenableFuture<Result> {
|
||||
if (BackgroundWorkerPreferences(ctx).isLocked() && BackgroundEngineLock.connectEngines > 0) {
|
||||
Log.i(TAG, "Foreground engine active, skipping background worker")
|
||||
return Futures.immediateFuture(Result.success())
|
||||
}
|
||||
|
||||
Log.i(TAG, "Starting background upload worker")
|
||||
|
||||
if (!loader.initialized()) {
|
||||
@@ -77,6 +83,10 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
|
||||
showNotification(notificationConfig.first, notificationConfig.second)
|
||||
|
||||
loader.ensureInitializationCompleteAsync(ctx, null, Handler(Looper.getMainLooper())) {
|
||||
if (isStopped || isComplete) {
|
||||
return@ensureInitializationCompleteAsync
|
||||
}
|
||||
|
||||
engine = FlutterEngine(ctx)
|
||||
FlutterEngineCache.getInstance().put(BackgroundWorkerApiImpl.ENGINE_CACHE_KEY, engine!!)
|
||||
|
||||
@@ -143,11 +153,17 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
|
||||
return
|
||||
}
|
||||
|
||||
val api = flutterApi
|
||||
if (api == null) {
|
||||
Handler(Looper.getMainLooper()).postAtFrontOfQueue {
|
||||
complete(Result.failure())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
Handler(Looper.getMainLooper()).postAtFrontOfQueue {
|
||||
if (flutterApi != null) {
|
||||
flutterApi?.cancel {
|
||||
complete(Result.failure())
|
||||
}
|
||||
api.cancel {
|
||||
complete(Result.failure())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -174,10 +174,9 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
|
||||
MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO -> 2L
|
||||
else -> 0L
|
||||
}
|
||||
// Date taken is in ms; date added/modified in seconds. No-EXIF (date taken <= 0)
|
||||
// falls back to the earliest of modified/added to match the server + iOS.
|
||||
// Date taken is milliseconds since epoch, Date added is seconds since epoch
|
||||
val createdAt = (c.getLong(dateTakenColumn).takeIf { it > 0 }?.div(1000))
|
||||
?: minOf(c.getLong(dateModifiedColumn), c.getLong(dateAddedColumn))
|
||||
?: c.getLong(dateAddedColumn)
|
||||
// Date modified is seconds since epoch
|
||||
val modifiedAt = c.getLong(dateModifiedColumn)
|
||||
val width = c.getInt(widthColumn).toLong()
|
||||
|
||||
@@ -12,14 +12,13 @@ import 'package:immich_mobile/domain/models/settings_key.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/settings.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
|
||||
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
|
||||
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
|
||||
|
||||
const int targetVersion = 27;
|
||||
const int targetVersion = 26;
|
||||
|
||||
Future<void> migrateDatabaseIfNeeded(Drift drift) async {
|
||||
final int version = Store.get(StoreKey.version, targetVersion);
|
||||
@@ -32,33 +31,10 @@ Future<void> migrateDatabaseIfNeeded(Drift drift) async {
|
||||
await _migrateTo26(drift);
|
||||
}
|
||||
|
||||
if (version < 27) {
|
||||
if (!await _migrateTo27(drift)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await Store.put(StoreKey.version, targetVersion);
|
||||
return;
|
||||
}
|
||||
|
||||
Future<bool> _migrateTo27(Drift drift) async {
|
||||
// Android-only: no-EXIF photos got a wrong createdAt (DATE_ADDED copy-time instead
|
||||
// of the real DATE_MODIFIED). Those rows can't self-heal -- the local sync only
|
||||
// updates an asset when its updatedAt (DATE_MODIFIED) changes, which it never does
|
||||
// here. A createdAt later than updatedAt is the copy-time signature, so clamp it
|
||||
// back to updatedAt (the real date, == the new minOf(DATE_MODIFIED, DATE_ADDED)).
|
||||
if (!CurrentPlatform.isAndroid) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
await drift.customStatement('UPDATE local_asset_entity SET created_at = updated_at WHERE created_at > updated_at');
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _migrateTo25() async {
|
||||
final accessToken = Store.tryGet(StoreKey.accessToken);
|
||||
if (accessToken == null || accessToken.isEmpty) {
|
||||
|
||||
Generated
+3
-11
@@ -982,9 +982,7 @@ class AssetsApi {
|
||||
/// * [String] key:
|
||||
///
|
||||
/// * [String] slug:
|
||||
///
|
||||
/// * [num] xImmichHlsPos:
|
||||
Future<Response> getMediaPlaylistWithHttpInfo(String id, String sessionId, int variantIndex, { String? key, String? slug, num? xImmichHlsPos, Future<void>? abortTrigger, }) async {
|
||||
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)
|
||||
@@ -1005,10 +1003,6 @@ class AssetsApi {
|
||||
queryParams.addAll(_queryParams('', 'slug', slug));
|
||||
}
|
||||
|
||||
if (xImmichHlsPos != null) {
|
||||
headerParams[r'x-immich-hls-pos'] = parameterToString(xImmichHlsPos);
|
||||
}
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
@@ -1039,10 +1033,8 @@ class AssetsApi {
|
||||
/// * [String] key:
|
||||
///
|
||||
/// * [String] slug:
|
||||
///
|
||||
/// * [num] xImmichHlsPos:
|
||||
Future<String?> getMediaPlaylist(String id, String sessionId, int variantIndex, { String? key, String? slug, num? xImmichHlsPos, Future<void>? abortTrigger, }) async {
|
||||
final response = await getMediaPlaylistWithHttpInfo(id, sessionId, variantIndex, key: key, slug: slug, xImmichHlsPos: xImmichHlsPos, abortTrigger: abortTrigger,);
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -4924,15 +4924,6 @@
|
||||
"maximum": 9007199254740991,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "x-immich-hls-pos",
|
||||
"required": false,
|
||||
"in": "header",
|
||||
"schema": {
|
||||
"minimum": 0,
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
|
||||
@@ -4452,13 +4452,12 @@ export function endSession({ id, key, sessionId, slug }: {
|
||||
/**
|
||||
* Get HLS media playlist
|
||||
*/
|
||||
export function getMediaPlaylist({ id, key, sessionId, slug, variantIndex, xImmichHlsPos }: {
|
||||
export function getMediaPlaylist({ id, key, sessionId, slug, variantIndex }: {
|
||||
id: string;
|
||||
key?: string;
|
||||
sessionId: string;
|
||||
slug?: string;
|
||||
variantIndex: number;
|
||||
xImmichHlsPos?: number;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchBlob<{
|
||||
status: 200;
|
||||
@@ -4467,10 +4466,7 @@ export function getMediaPlaylist({ id, key, sessionId, slug, variantIndex, xImmi
|
||||
key,
|
||||
slug
|
||||
}))}`, {
|
||||
...opts,
|
||||
headers: oazapfts.mergeHeaders(opts?.headers, {
|
||||
"x-immich-hls-pos": xImmichHlsPos
|
||||
})
|
||||
...opts
|
||||
}));
|
||||
}
|
||||
/**
|
||||
|
||||
@@ -223,12 +223,6 @@ export const SUPPORTED_HWA_CODECS: Record<TranscodeHardwareAcceleration, VideoCo
|
||||
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_CRF: Record<VideoCodec, number> = {
|
||||
[VideoCodec.H264]: 23,
|
||||
[VideoCodec.Hevc]: 28,
|
||||
[VideoCodec.Vp9]: 31,
|
||||
[VideoCodec.Av1]: 35,
|
||||
};
|
||||
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';
|
||||
|
||||
@@ -6,7 +6,6 @@ import { HLS_PLAYLIST_CONTENT_TYPE } from 'src/constants';
|
||||
import { Endpoint, HistoryBuilder } from 'src/decorators';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import {
|
||||
HlsPlaylistHeaderDto,
|
||||
HlsSegmentHeaderDto,
|
||||
HlsSegmentParamDto,
|
||||
HlsSessionParamDto,
|
||||
@@ -51,17 +50,8 @@ export class VideoStreamController {
|
||||
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, variantIndex }: HlsVariantParamDto,
|
||||
@Headers() headers: HlsPlaylistHeaderDto,
|
||||
) {
|
||||
try {
|
||||
headers = HlsPlaylistHeaderDto.create(headers);
|
||||
} catch (error) {
|
||||
throw new ZodValidationException(error);
|
||||
}
|
||||
return this.service.getMediaPlaylist(auth, id, sessionId, variantIndex, headers[ImmichHeader.HlsPosition]);
|
||||
getMediaPlaylist(@Auth() auth: AuthDto, @Param() { id, sessionId }: HlsVariantParamDto) {
|
||||
return this.service.getMediaPlaylist(auth, id, sessionId);
|
||||
}
|
||||
|
||||
@Get(':id/video/stream/:sessionId/:variantIndex/:filename')
|
||||
|
||||
@@ -32,11 +32,3 @@ const HlsSegmentHeaderSchema = z.object({
|
||||
});
|
||||
|
||||
export class HlsSegmentHeaderDto extends createZodDto(HlsSegmentHeaderSchema) {}
|
||||
|
||||
const HlsPlaylistHeaderSchema = z.object({
|
||||
// Lets the client hint at which segment will be loaded after the playlist.
|
||||
// A position rather than a segment index since indices aren't comparable across variants.
|
||||
[ImmichHeader.HlsPosition]: z.coerce.number().min(0).optional(),
|
||||
});
|
||||
|
||||
export class HlsPlaylistHeaderDto extends createZodDto(HlsPlaylistHeaderSchema) {}
|
||||
|
||||
@@ -25,7 +25,6 @@ export enum ImmichHeader {
|
||||
Checksum = 'x-immich-checksum',
|
||||
CorrelationId = 'X-Correlation-ID',
|
||||
HlsInitSegment = 'x-immich-hls-msn',
|
||||
HlsPosition = 'x-immich-hls-pos',
|
||||
}
|
||||
|
||||
export enum ImmichQuery {
|
||||
|
||||
@@ -8,7 +8,6 @@ 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-INDEPENDENT-SEGMENTS
|
||||
#EXT-X-TARGETDURATION:2
|
||||
#EXT-X-MEDIA-SEQUENCE:0
|
||||
#EXT-X-PLAYLIST-TYPE:VOD
|
||||
@@ -42,7 +41,6 @@ seg_11.m4s
|
||||
|
||||
const waterfallExpectedMediaPlaylist = `#EXTM3U
|
||||
#EXT-X-VERSION:7
|
||||
#EXT-X-INDEPENDENT-SEGMENTS
|
||||
#EXT-X-TARGETDURATION:2
|
||||
#EXT-X-MEDIA-SEQUENCE:0
|
||||
#EXT-X-PLAYLIST-TYPE:VOD
|
||||
@@ -64,7 +62,6 @@ seg_5.m4s
|
||||
|
||||
const trainExpectedMediaPlaylist = `#EXTM3U
|
||||
#EXT-X-VERSION:7
|
||||
#EXT-X-INDEPENDENT-SEGMENTS
|
||||
#EXT-X-TARGETDURATION:2
|
||||
#EXT-X-MEDIA-SEQUENCE:0
|
||||
#EXT-X-PLAYLIST-TYPE:VOD
|
||||
@@ -98,7 +95,6 @@ const sessionId = '00000000-0000-0000-0000-000000000000';
|
||||
|
||||
const eiffelExpectedMasterDisabled = `#EXTM3U
|
||||
#EXT-X-VERSION:7
|
||||
#EXT-X-INDEPENDENT-SEGMENTS
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=1000000,RESOLUTION=480x852,CODECS="av01.0.04M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
${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
|
||||
@@ -121,7 +117,6 @@ ${sessionId}/8/playlist.m3u8
|
||||
|
||||
const eiffelExpectedMasterRkmpp = `#EXTM3U
|
||||
#EXT-X-VERSION:7
|
||||
#EXT-X-INDEPENDENT-SEGMENTS
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=1200000,RESOLUTION=480x852,CODECS="hvc1.1.6.L90.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
${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
|
||||
@@ -138,7 +133,6 @@ ${sessionId}/8/playlist.m3u8
|
||||
|
||||
const waterfallExpectedMasterDisabled = `#EXTM3U
|
||||
#EXT-X-VERSION:7
|
||||
#EXT-X-INDEPENDENT-SEGMENTS
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=1000000,RESOLUTION=480x852,CODECS="av01.0.04M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||
${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
|
||||
@@ -224,58 +218,12 @@ describe(HlsService.name, () => {
|
||||
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, 0)).resolves.toBe(playlist);
|
||||
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, 0)).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('prewarms transcoding at the segment containing the hinted position', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
|
||||
mocks.videoStream.getForMediaPlaylist.mockResolvedValue(eiffelTower);
|
||||
await sut.getMediaPlaylist(auth, assetId, sessionId, 1, 10.5);
|
||||
expect(mocks.websocket.serverSend).toHaveBeenCalledWith('HlsSegmentRequest', {
|
||||
sessionId,
|
||||
assetId,
|
||||
variantIndex: 1,
|
||||
segmentIndex: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('prewarms from the last requested segment when no hint is given', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
|
||||
mocks.videoStream.getSession.mockResolvedValue({ id: sessionId, assetId } as never);
|
||||
mocks.storage.checkFileExists.mockResolvedValue(true);
|
||||
await sut.getSegment(auth, assetId, sessionId, 0, 'seg_5.m4s');
|
||||
|
||||
mocks.videoStream.getForMediaPlaylist.mockResolvedValue(eiffelTower);
|
||||
await sut.getMediaPlaylist(auth, assetId, sessionId, 1);
|
||||
expect(mocks.websocket.serverSend).toHaveBeenCalledWith('HlsSegmentRequest', {
|
||||
sessionId,
|
||||
assetId,
|
||||
variantIndex: 1,
|
||||
segmentIndex: 6,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not prewarm without a hint or prior segment traffic', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
|
||||
mocks.videoStream.getForMediaPlaylist.mockResolvedValue(eiffelTower);
|
||||
await sut.getMediaPlaylist(auth, assetId, sessionId, 1);
|
||||
expect(mocks.websocket.serverSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not prewarm the variant the session is already playing', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
|
||||
mocks.videoStream.getSession.mockResolvedValue({ id: sessionId, assetId } as never);
|
||||
mocks.storage.checkFileExists.mockResolvedValue(true);
|
||||
await sut.getSegment(auth, assetId, sessionId, 1, 'seg_5.m4s');
|
||||
|
||||
mocks.videoStream.getForMediaPlaylist.mockResolvedValue(eiffelTower);
|
||||
await sut.getMediaPlaylist(auth, assetId, sessionId, 1, 12.5);
|
||||
expect(mocks.websocket.serverSend).not.toHaveBeenCalledWith('HlsSegmentRequest', expect.anything());
|
||||
await expect(sut.getMediaPlaylist(auth, assetId, sessionId)).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -366,7 +314,7 @@ describe(HlsService.name, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the initSegment hint for init.mp4', async () => {
|
||||
it('uses the target segment for init.mp4 when provided', async () => {
|
||||
await sut.getSegment(auth, assetId, sessionId, variantIndex, 'init.mp4', 7);
|
||||
expect(mocks.websocket.serverSend).toHaveBeenCalledWith('HlsHeartbeat', {
|
||||
sessionId,
|
||||
@@ -375,18 +323,18 @@ describe(HlsService.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers the initSegment hint over the lastRequested + 1 fallback', async () => {
|
||||
it('prefers the target segment over the lastRequested + 1 fallback', async () => {
|
||||
await sut.getSegment(auth, assetId, sessionId, variantIndex, 'seg_5.m4s'); // fallback would be 6
|
||||
mocks.websocket.serverSend.mockClear();
|
||||
await sut.getSegment(auth, assetId, sessionId, variantIndex, 'init.mp4', 10);
|
||||
await sut.getSegment(auth, assetId, sessionId, variantIndex, 'init.mp4', 12);
|
||||
expect(mocks.websocket.serverSend).toHaveBeenCalledWith('HlsHeartbeat', {
|
||||
sessionId,
|
||||
variantIndex,
|
||||
segmentIndex: 10,
|
||||
segmentIndex: 12,
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores the initSegment hint for media segment requests (the filename wins)', async () => {
|
||||
it('ignores the target segment for media segment requests (the filename wins)', async () => {
|
||||
await sut.getSegment(auth, assetId, sessionId, variantIndex, 'seg_5.m4s', 99);
|
||||
expect(mocks.websocket.serverSend).toHaveBeenCalledWith('HlsHeartbeat', {
|
||||
sessionId,
|
||||
|
||||
@@ -21,7 +21,6 @@ import { ImmichFileResponse } from 'src/utils/file';
|
||||
import { getOutputSize } from 'src/utils/media';
|
||||
|
||||
type AssetWithStreamInfo = { videoStream: VideoStreamInfo & { timeBase: number }; packets: VideoPacketInfo };
|
||||
type Segmentation = { fps: number; framesPerSegment: number; segmentCount: number; segmentDuration: number };
|
||||
type ApiSession = { lastRequestedSegment: number | null; lastVariantIndex: number | null };
|
||||
|
||||
@Injectable()
|
||||
@@ -72,7 +71,7 @@ export class HlsService extends BaseService {
|
||||
return this.generateMainPlaylist(sessionId, ffmpeg, asset);
|
||||
}
|
||||
|
||||
async getMediaPlaylist(auth: AuthDto, assetId: string, sessionId: string, variantIndex: number, position?: number) {
|
||||
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);
|
||||
@@ -80,11 +79,7 @@ export class HlsService extends BaseService {
|
||||
throw new NotFoundException('Asset not found or metadata not yet ready for streaming');
|
||||
}
|
||||
|
||||
const segmentation = this.getSegmentation(asset);
|
||||
const hintedSegment = position === undefined ? undefined : this.positionToSegmentIndex(segmentation, position);
|
||||
this.prewarmVariant(assetId, sessionId, variantIndex, hintedSegment);
|
||||
|
||||
return this.generateMediaPlaylist(asset, segmentation);
|
||||
return this.generateMediaPlaylist(asset);
|
||||
}
|
||||
|
||||
async getSegment(
|
||||
@@ -134,7 +129,7 @@ export class HlsService extends BaseService {
|
||||
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}`, '#EXT-X-INDEPENDENT-SEGMENTS'];
|
||||
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)) {
|
||||
@@ -148,33 +143,24 @@ export class HlsService extends BaseService {
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
if (lines.length === 4) {
|
||||
if (lines.length === 3) {
|
||||
throw new NotFoundException('No supported variants for this video');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
private getSegmentation({ videoStream, packets }: AssetWithStreamInfo): Segmentation {
|
||||
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);
|
||||
return { fps, framesPerSegment, segmentCount, segmentDuration: framesPerSegment / fps };
|
||||
}
|
||||
|
||||
private positionToSegmentIndex({ segmentDuration, segmentCount }: Segmentation, position: number) {
|
||||
return Math.min(Math.max(Math.floor(position / segmentDuration), 0), segmentCount - 1);
|
||||
}
|
||||
|
||||
private generateMediaPlaylist({ packets }: AssetWithStreamInfo, segmentation: Segmentation) {
|
||||
const { fps, framesPerSegment, segmentCount, segmentDuration: fullSegmentDuration } = segmentation;
|
||||
const lastSegmentFrames = packets.outputFrames - framesPerSegment * (segmentCount - 1);
|
||||
const lastSegmentDuration = lastSegmentFrames / fps;
|
||||
|
||||
const lines = [
|
||||
'#EXTM3U',
|
||||
`#EXT-X-VERSION:${HLS_VERSION}`,
|
||||
'#EXT-X-INDEPENDENT-SEGMENTS',
|
||||
`#EXT-X-TARGETDURATION:${HLS_SEGMENT_DURATION}`,
|
||||
'#EXT-X-MEDIA-SEQUENCE:0',
|
||||
'#EXT-X-PLAYLIST-TYPE:VOD',
|
||||
@@ -189,19 +175,6 @@ export class HlsService extends BaseService {
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
private prewarmVariant(assetId: string, sessionId: string, variantIndex: number, hintedSegment?: number) {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (session?.lastVariantIndex === variantIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSegment = session && session.lastRequestedSegment !== null ? session.lastRequestedSegment + 1 : undefined;
|
||||
const segmentIndex = hintedSegment ?? nextSegment;
|
||||
if (segmentIndex !== undefined) {
|
||||
this.websocketRepository.serverSend('HlsSegmentRequest', { sessionId, assetId, variantIndex, segmentIndex });
|
||||
}
|
||||
}
|
||||
|
||||
private getSegmentKey({ sessionId, variantIndex, segmentIndex }: ArgOf<'HlsSegmentResult'>) {
|
||||
return `${sessionId}:${variantIndex}:${segmentIndex}`;
|
||||
}
|
||||
|
||||
@@ -381,6 +381,8 @@ describe(TranscodingService.name, () => {
|
||||
'50',
|
||||
'-keyint_min',
|
||||
'50',
|
||||
'-crf',
|
||||
'23',
|
||||
'-copyts',
|
||||
'-r',
|
||||
'50130000/2012441',
|
||||
@@ -415,8 +417,6 @@ describe(TranscodingService.name, () => {
|
||||
'aac',
|
||||
'-preset',
|
||||
'12',
|
||||
'-crf',
|
||||
'35',
|
||||
'-svtav1-params',
|
||||
'hierarchical-levels=3:lookahead=0:enable-tf=0:mbr=4000k',
|
||||
'-hls_segment_filename',
|
||||
@@ -436,8 +436,6 @@ describe(TranscodingService.name, () => {
|
||||
'hvc1',
|
||||
'-preset',
|
||||
'ultrafast',
|
||||
'-crf',
|
||||
'28',
|
||||
'-maxrate',
|
||||
'2500k',
|
||||
'-bufsize',
|
||||
@@ -461,8 +459,6 @@ describe(TranscodingService.name, () => {
|
||||
'aac',
|
||||
'-preset',
|
||||
'ultrafast',
|
||||
'-crf',
|
||||
'23',
|
||||
'-maxrate',
|
||||
'2500k',
|
||||
'-bufsize',
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
HLS_BACKPRESSURE_PAUSE_SEGMENTS,
|
||||
HLS_BACKPRESSURE_RESUME_SEGMENTS,
|
||||
HLS_CLEANUP_INTERVAL_MS,
|
||||
HLS_CRF,
|
||||
HLS_INACTIVITY_TIMEOUT_MS,
|
||||
HLS_LEASE_DURATION_MS,
|
||||
HLS_SEGMENT_DURATION,
|
||||
@@ -222,7 +221,6 @@ export class TranscodingService extends BaseService {
|
||||
targetResolution: String(variant.resolution),
|
||||
maxBitrate: `${Math.round(variant.bitrate / 1000)}k`,
|
||||
gopSize: gop,
|
||||
crf: HLS_CRF[variant.codec],
|
||||
},
|
||||
this.videoInterfaces,
|
||||
{ strictGop: true, lowLatency: true },
|
||||
|
||||
Reference in New Issue
Block a user