mirror of
https://github.com/immich-app/immich.git
synced 2026-05-21 15:16:31 -04:00
Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f1de18d4f | |||
| f93c167f68 | |||
| aa3ca57511 | |||
| 596e8ea397 | |||
| 24ad0fed53 | |||
| 0c0fac9fb0 | |||
| 9341037b0a | |||
| ed7e3c8933 | |||
| 9a326365ca | |||
| 13f08a034f | |||
| 5fcaa6ed6b | |||
| 530edde7f5 | |||
| bd228040f8 | |||
| ac004e9d84 | |||
| 96ba978cc8 | |||
| c8bdf3678f | |||
| 89bbe3dd93 | |||
| 4b7cff4b79 | |||
| 1b8a5f3307 | |||
| 4ec06aaa38 | |||
| ba2f1b9842 | |||
| e058ef868d | |||
| 5243807395 | |||
| d7d25aa072 | |||
| fb5203dc4b | |||
| edb8a0dcd8 | |||
| c5a88f0e51 | |||
| a88e69bc9d | |||
| 27cf03c850 | |||
| f7d9f817ab | |||
| 9b75b37d82 | |||
| e4f6129419 | |||
| f13627bcb2 | |||
| 1e7d9645e8 | |||
| 60d4def0c8 | |||
| 75db82da12 | |||
| 7acda0572d | |||
| 98bc9f6a6e | |||
| 63a3b405c3 | |||
| 0058df798d | |||
| 97100a4362 | |||
| af39384efb | |||
| 01712cf0a7 | |||
| 2015f95ff5 | |||
| d4f29ab6ac |
@@ -1,5 +1,5 @@
|
||||
[tools]
|
||||
terragrunt = "1.0.2"
|
||||
terragrunt = "1.0.3"
|
||||
opentofu = "1.11.6"
|
||||
|
||||
[tasks."tg:fmt"]
|
||||
|
||||
@@ -97,7 +97,7 @@ services:
|
||||
command: ['./run.sh', '-disable-reporting']
|
||||
ports:
|
||||
- 3000:3000
|
||||
image: grafana/grafana:12.4.2-ubuntu@sha256:78839fe49e1425c02416fa8072591533a72bd9598e563b54a07d78f9e27fb5d3
|
||||
image: grafana/grafana:12.4.3-ubuntu@sha256:ca3f764fdc48cebdf22dd206f33ecb0795a9a7210eacd1b5c02204aebd78b223
|
||||
volumes:
|
||||
- grafana-data:/var/lib/grafana
|
||||
|
||||
|
||||
@@ -401,6 +401,10 @@
|
||||
"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",
|
||||
@@ -2389,6 +2393,7 @@
|
||||
"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",
|
||||
|
||||
@@ -17,7 +17,7 @@ config_roots = [
|
||||
node = "24.15.0"
|
||||
flutter = "3.41.7"
|
||||
pnpm = "10.33.1"
|
||||
terragrunt = "1.0.2"
|
||||
terragrunt = "1.0.3"
|
||||
opentofu = "1.11.6"
|
||||
java = "21.0.2"
|
||||
|
||||
|
||||
+13
-145
@@ -2779,17 +2779,15 @@
|
||||
},
|
||||
{
|
||||
"id": 32,
|
||||
"references": [
|
||||
1
|
||||
],
|
||||
"references": [],
|
||||
"type": "table",
|
||||
"data": {
|
||||
"name": "asset_ocr_entity",
|
||||
"name": "metadata",
|
||||
"was_declared_in_moor": false,
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"getter_name": "id",
|
||||
"name": "key",
|
||||
"getter_name": "key",
|
||||
"moor_type": "string",
|
||||
"nullable": false,
|
||||
"customConstraints": null,
|
||||
@@ -2798,134 +2796,8 @@
|
||||
"dsl_features": []
|
||||
},
|
||||
{
|
||||
"name": "asset_id",
|
||||
"getter_name": "assetId",
|
||||
"moor_type": "string",
|
||||
"nullable": false,
|
||||
"customConstraints": null,
|
||||
"defaultConstraints": "REFERENCES remote_asset_entity (id) ON DELETE CASCADE",
|
||||
"dialectAwareDefaultConstraints": {
|
||||
"sqlite": "REFERENCES remote_asset_entity (id) ON DELETE CASCADE"
|
||||
},
|
||||
"default_dart": null,
|
||||
"default_client_dart": null,
|
||||
"dsl_features": [
|
||||
{
|
||||
"foreign_key": {
|
||||
"to": {
|
||||
"table": "remote_asset_entity",
|
||||
"column": "id"
|
||||
},
|
||||
"initially_deferred": false,
|
||||
"on_update": null,
|
||||
"on_delete": "cascade"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "x1",
|
||||
"getter_name": "x1",
|
||||
"moor_type": "double",
|
||||
"nullable": false,
|
||||
"customConstraints": null,
|
||||
"default_dart": null,
|
||||
"default_client_dart": null,
|
||||
"dsl_features": []
|
||||
},
|
||||
{
|
||||
"name": "y1",
|
||||
"getter_name": "y1",
|
||||
"moor_type": "double",
|
||||
"nullable": false,
|
||||
"customConstraints": null,
|
||||
"default_dart": null,
|
||||
"default_client_dart": null,
|
||||
"dsl_features": []
|
||||
},
|
||||
{
|
||||
"name": "x2",
|
||||
"getter_name": "x2",
|
||||
"moor_type": "double",
|
||||
"nullable": false,
|
||||
"customConstraints": null,
|
||||
"default_dart": null,
|
||||
"default_client_dart": null,
|
||||
"dsl_features": []
|
||||
},
|
||||
{
|
||||
"name": "y2",
|
||||
"getter_name": "y2",
|
||||
"moor_type": "double",
|
||||
"nullable": false,
|
||||
"customConstraints": null,
|
||||
"default_dart": null,
|
||||
"default_client_dart": null,
|
||||
"dsl_features": []
|
||||
},
|
||||
{
|
||||
"name": "x3",
|
||||
"getter_name": "x3",
|
||||
"moor_type": "double",
|
||||
"nullable": false,
|
||||
"customConstraints": null,
|
||||
"default_dart": null,
|
||||
"default_client_dart": null,
|
||||
"dsl_features": []
|
||||
},
|
||||
{
|
||||
"name": "y3",
|
||||
"getter_name": "y3",
|
||||
"moor_type": "double",
|
||||
"nullable": false,
|
||||
"customConstraints": null,
|
||||
"default_dart": null,
|
||||
"default_client_dart": null,
|
||||
"dsl_features": []
|
||||
},
|
||||
{
|
||||
"name": "x4",
|
||||
"getter_name": "x4",
|
||||
"moor_type": "double",
|
||||
"nullable": false,
|
||||
"customConstraints": null,
|
||||
"default_dart": null,
|
||||
"default_client_dart": null,
|
||||
"dsl_features": []
|
||||
},
|
||||
{
|
||||
"name": "y4",
|
||||
"getter_name": "y4",
|
||||
"moor_type": "double",
|
||||
"nullable": false,
|
||||
"customConstraints": null,
|
||||
"default_dart": null,
|
||||
"default_client_dart": null,
|
||||
"dsl_features": []
|
||||
},
|
||||
{
|
||||
"name": "box_score",
|
||||
"getter_name": "boxScore",
|
||||
"moor_type": "double",
|
||||
"nullable": false,
|
||||
"customConstraints": null,
|
||||
"default_dart": null,
|
||||
"default_client_dart": null,
|
||||
"dsl_features": []
|
||||
},
|
||||
{
|
||||
"name": "text_score",
|
||||
"getter_name": "textScore",
|
||||
"moor_type": "double",
|
||||
"nullable": false,
|
||||
"customConstraints": null,
|
||||
"default_dart": null,
|
||||
"default_client_dart": null,
|
||||
"dsl_features": []
|
||||
},
|
||||
{
|
||||
"name": "recognized_text",
|
||||
"getter_name": "recognizedText",
|
||||
"name": "value",
|
||||
"getter_name": "value",
|
||||
"moor_type": "string",
|
||||
"nullable": false,
|
||||
"customConstraints": null,
|
||||
@@ -2934,16 +2806,12 @@
|
||||
"dsl_features": []
|
||||
},
|
||||
{
|
||||
"name": "is_visible",
|
||||
"getter_name": "isVisible",
|
||||
"moor_type": "bool",
|
||||
"name": "updated_at",
|
||||
"getter_name": "updatedAt",
|
||||
"moor_type": "dateTime",
|
||||
"nullable": false,
|
||||
"customConstraints": null,
|
||||
"defaultConstraints": "CHECK (\"is_visible\" IN (0, 1))",
|
||||
"dialectAwareDefaultConstraints": {
|
||||
"sqlite": "CHECK (\"is_visible\" IN (0, 1))"
|
||||
},
|
||||
"default_dart": "const CustomExpression('1')",
|
||||
"default_dart": "const CustomExpression('CURRENT_TIMESTAMP')",
|
||||
"default_client_dart": null,
|
||||
"dsl_features": []
|
||||
}
|
||||
@@ -2953,7 +2821,7 @@
|
||||
"constraints": [],
|
||||
"strict": true,
|
||||
"explicit_pk": [
|
||||
"id"
|
||||
"key"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -3388,11 +3256,11 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "asset_ocr_entity",
|
||||
"name": "metadata",
|
||||
"sql": [
|
||||
{
|
||||
"dialect": "sqlite",
|
||||
"sql": "CREATE TABLE IF NOT EXISTS \"asset_ocr_entity\" (\"id\" TEXT NOT NULL, \"asset_id\" TEXT NOT NULL REFERENCES remote_asset_entity (id) ON DELETE CASCADE, \"x1\" REAL NOT NULL, \"y1\" REAL NOT NULL, \"x2\" REAL NOT NULL, \"y2\" REAL NOT NULL, \"x3\" REAL NOT NULL, \"y3\" REAL NOT NULL, \"x4\" REAL NOT NULL, \"y4\" REAL NOT NULL, \"box_score\" REAL NOT NULL, \"text_score\" REAL NOT NULL, \"recognized_text\" TEXT NOT NULL, \"is_visible\" INTEGER NOT NULL DEFAULT 1 CHECK (\"is_visible\" IN (0, 1)), PRIMARY KEY (\"id\")) WITHOUT ROWID, STRICT;"
|
||||
"sql": "CREATE TABLE IF NOT EXISTS \"metadata\" (\"key\" TEXT NOT NULL, \"value\" TEXT NOT NULL, \"updated_at\" TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP), PRIMARY KEY (\"key\")) WITHOUT ROWID, STRICT;"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'package:immich_mobile/domain/models/config/theme_config.dart';
|
||||
|
||||
class AppConfig {
|
||||
final ThemeConfig theme;
|
||||
|
||||
const AppConfig({this.theme = const ThemeConfig()});
|
||||
|
||||
AppConfig copyWith({ThemeConfig? theme}) => .new(theme: theme ?? this.theme);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || (other is AppConfig && other.theme == theme);
|
||||
|
||||
@override
|
||||
int get hashCode => theme.hashCode;
|
||||
|
||||
@override
|
||||
String toString() => 'AppConfig(theme: $theme)';
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'package:immich_mobile/domain/models/log.model.dart';
|
||||
|
||||
class SystemConfig {
|
||||
final LogLevel logLevel;
|
||||
|
||||
const SystemConfig({this.logLevel = .info});
|
||||
|
||||
SystemConfig copyWith({LogLevel? logLevel}) => SystemConfig(logLevel: logLevel ?? this.logLevel);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || (other is SystemConfig && other.logLevel == logLevel);
|
||||
|
||||
@override
|
||||
int get hashCode => logLevel.hashCode;
|
||||
|
||||
@override
|
||||
String toString() => 'SystemConfig(logLevel: $logLevel)';
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ThemeConfig {
|
||||
final ThemeMode mode;
|
||||
|
||||
const ThemeConfig({this.mode = .system});
|
||||
|
||||
ThemeConfig copyWith({ThemeMode? mode}) => .new(mode: mode ?? this.mode);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || (other is ThemeConfig && other.mode == mode);
|
||||
|
||||
@override
|
||||
int get hashCode => mode.hashCode;
|
||||
|
||||
@override
|
||||
String toString() => 'ThemeConfig(mode: $mode)';
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/domain/models/config/app_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/system_config.dart';
|
||||
import 'package:immich_mobile/domain/models/log.model.dart';
|
||||
|
||||
enum MetadataDomain<T extends Object> {
|
||||
appConfig<AppConfig>('config.app'),
|
||||
systemConfig<SystemConfig>('config.system');
|
||||
|
||||
final String prefix;
|
||||
const MetadataDomain(this.prefix);
|
||||
}
|
||||
|
||||
enum MetadataKey<T extends Object> {
|
||||
themeMode<ThemeMode>(.appConfig, 'theme.mode', .system, _EnumCodec(ThemeMode.values)),
|
||||
logLevel<LogLevel>(.systemConfig, 'log.level', .info, _EnumCodec(LogLevel.values));
|
||||
|
||||
final MetadataDomain domain;
|
||||
final String name;
|
||||
final T defaultValue;
|
||||
final _MetadataCodec<T>? _codecOverride;
|
||||
|
||||
const MetadataKey(this.domain, this.name, this.defaultValue, [this._codecOverride]);
|
||||
|
||||
String get key => '${domain.prefix}.$name';
|
||||
|
||||
_MetadataCodec<T> get _codec => _codecOverride ?? _MetadataCodec.forPrimitive(defaultValue);
|
||||
|
||||
String encode(T value) => _codec.encode(value);
|
||||
|
||||
T decode(String raw) => _codec.decode(raw) ?? defaultValue;
|
||||
|
||||
static Map<String, MetadataKey<Object>> asKeyMap() => {for (var value in MetadataKey.values) value.key: value};
|
||||
}
|
||||
|
||||
sealed class _MetadataCodec<T extends Object> {
|
||||
const _MetadataCodec();
|
||||
|
||||
String encode(T value);
|
||||
T? decode(String raw);
|
||||
|
||||
static const Map<Type, _MetadataCodec<Object>> _primitives = {
|
||||
int: _PrimitiveCodec.integer,
|
||||
double: _PrimitiveCodec.real,
|
||||
bool: _PrimitiveCodec.boolean,
|
||||
String: _PrimitiveCodec.string,
|
||||
DateTime: _DateTimeCodec(),
|
||||
};
|
||||
|
||||
static _MetadataCodec<T> forPrimitive<T extends Object>(T sample) {
|
||||
final codec = _primitives[sample.runtimeType];
|
||||
if (codec == null) {
|
||||
throw StateError(
|
||||
'No primitive codec for ${sample.runtimeType}. Provide an explicit codec when defining the MetadataKey.',
|
||||
);
|
||||
}
|
||||
return codec as _MetadataCodec<T>;
|
||||
}
|
||||
}
|
||||
|
||||
final class _EnumCodec<T extends Enum> extends _MetadataCodec<T> {
|
||||
final List<T> values;
|
||||
|
||||
const _EnumCodec(this.values);
|
||||
|
||||
@override
|
||||
String encode(T value) => value.name;
|
||||
|
||||
@override
|
||||
T? decode(String raw) => values.firstWhereOrNull((v) => v.name == raw);
|
||||
}
|
||||
|
||||
final class _DateTimeCodec extends _MetadataCodec<DateTime> {
|
||||
const _DateTimeCodec();
|
||||
|
||||
@override
|
||||
String encode(DateTime value) => value.toIso8601String();
|
||||
|
||||
@override
|
||||
DateTime? decode(String raw) => DateTime.tryParse(raw);
|
||||
}
|
||||
|
||||
final class _PrimitiveCodec<T extends Object> extends _MetadataCodec<T> {
|
||||
final T? Function(String) _parse;
|
||||
|
||||
const _PrimitiveCodec._(this._parse);
|
||||
|
||||
@override
|
||||
String encode(T value) => value.toString();
|
||||
|
||||
@override
|
||||
T? decode(String raw) => _parse(raw);
|
||||
|
||||
static const integer = _PrimitiveCodec<int>._(int.tryParse);
|
||||
static const real = _PrimitiveCodec<double>._(double.tryParse);
|
||||
static const boolean = _PrimitiveCodec<bool>._(bool.tryParse);
|
||||
static const string = _PrimitiveCodec<String>._(_identity);
|
||||
|
||||
static String? _identity(String s) => s;
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
class Ocr {
|
||||
final String id;
|
||||
final String assetId;
|
||||
final double x1;
|
||||
final double y1;
|
||||
final double x2;
|
||||
final double y2;
|
||||
final double x3;
|
||||
final double y3;
|
||||
final double x4;
|
||||
final double y4;
|
||||
final double boxScore;
|
||||
final double textScore;
|
||||
final String text;
|
||||
final bool isVisible;
|
||||
|
||||
const Ocr({
|
||||
required this.id,
|
||||
required this.assetId,
|
||||
required this.x1,
|
||||
required this.y1,
|
||||
required this.x2,
|
||||
required this.y2,
|
||||
required this.x3,
|
||||
required this.y3,
|
||||
required this.x4,
|
||||
required this.y4,
|
||||
required this.boxScore,
|
||||
required this.textScore,
|
||||
required this.text,
|
||||
required this.isVisible,
|
||||
});
|
||||
|
||||
Ocr copyWith({
|
||||
String? id,
|
||||
String? assetId,
|
||||
double? x1,
|
||||
double? y1,
|
||||
double? x2,
|
||||
double? y2,
|
||||
double? x3,
|
||||
double? y3,
|
||||
double? x4,
|
||||
double? y4,
|
||||
double? boxScore,
|
||||
double? textScore,
|
||||
String? text,
|
||||
bool? isVisible,
|
||||
}) {
|
||||
return Ocr(
|
||||
id: id ?? this.id,
|
||||
assetId: assetId ?? this.assetId,
|
||||
x1: x1 ?? this.x1,
|
||||
y1: y1 ?? this.y1,
|
||||
x2: x2 ?? this.x2,
|
||||
y2: y2 ?? this.y2,
|
||||
x3: x3 ?? this.x3,
|
||||
y3: y3 ?? this.y3,
|
||||
x4: x4 ?? this.x4,
|
||||
y4: y4 ?? this.y4,
|
||||
boxScore: boxScore ?? this.boxScore,
|
||||
textScore: textScore ?? this.textScore,
|
||||
text: text ?? this.text,
|
||||
isVisible: isVisible ?? this.isVisible,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '''Ocr {
|
||||
id: $id,
|
||||
assetId: $assetId,
|
||||
x1: $x1,
|
||||
y1: $y1,
|
||||
x2: $x2,
|
||||
y2: $y2,
|
||||
x3: $x3,
|
||||
y3: $y3,
|
||||
x4: $x4,
|
||||
y4: $y4,
|
||||
boxScore: $boxScore,
|
||||
textScore: $textScore,
|
||||
text: $text,
|
||||
isVisible: $isVisible
|
||||
}''';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is Ocr &&
|
||||
other.id == id &&
|
||||
other.assetId == assetId &&
|
||||
other.x1 == x1 &&
|
||||
other.y1 == y1 &&
|
||||
other.x2 == x2 &&
|
||||
other.y2 == y2 &&
|
||||
other.x3 == x3 &&
|
||||
other.y3 == y3 &&
|
||||
other.x4 == x4 &&
|
||||
other.y4 == y4 &&
|
||||
other.boxScore == boxScore &&
|
||||
other.textScore == textScore &&
|
||||
other.text == text &&
|
||||
other.isVisible == isVisible;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return id.hashCode ^
|
||||
assetId.hashCode ^
|
||||
x1.hashCode ^
|
||||
y1.hashCode ^
|
||||
x2.hashCode ^
|
||||
y2.hashCode ^
|
||||
x3.hashCode ^
|
||||
y3.hashCode ^
|
||||
x4.hashCode ^
|
||||
y4.hashCode ^
|
||||
boxScore.hashCode ^
|
||||
textScore.hashCode ^
|
||||
text.hashCode ^
|
||||
isVisible.hashCode;
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,6 @@ enum StoreKey<T> {
|
||||
// user settings from [AppSettingsEnum] below:
|
||||
loadPreview<bool>._(100),
|
||||
loadOriginal<bool>._(101),
|
||||
themeMode<String>._(102),
|
||||
tilesPerRow<int>._(103),
|
||||
dynamicLayout<bool>._(104),
|
||||
groupAssetsBy<int>._(105),
|
||||
@@ -35,7 +34,6 @@ enum StoreKey<T> {
|
||||
albumThumbnailCacheSize<int>._(112),
|
||||
selectedAlbumSortOrder<int>._(113),
|
||||
advancedTroubleshooting<bool>._(114),
|
||||
logLevel<int>._(115),
|
||||
preferRemoteImage<bool>._(116),
|
||||
loopVideo<bool>._(117),
|
||||
// map related settings
|
||||
@@ -94,7 +92,11 @@ enum StoreKey<T> {
|
||||
cleanupCutoffDaysAgo<int>._(1011),
|
||||
cleanupDefaultsInitialized<bool>._(1012),
|
||||
|
||||
syncMigrationStatus<String>._(1013);
|
||||
syncMigrationStatus<String>._(1013),
|
||||
|
||||
// Legacy keys that have been migrated to the new metadata store
|
||||
legacyThemeMode<String>._(102),
|
||||
legacyLogLevel<int>._(115);
|
||||
|
||||
const StoreKey._(this.id);
|
||||
final int id;
|
||||
|
||||
@@ -2,20 +2,20 @@ import 'dart:async';
|
||||
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/log.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/models/metadata_key.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
/// Service responsible for handling application logging.
|
||||
///
|
||||
/// It listens to Dart's [Logger.root], buffers logs in memory (optionally),
|
||||
/// writes them to a persistent [ILogRepository], and manages log levels
|
||||
/// via [IStoreRepository]
|
||||
/// writes them to a persistent [LogRepository], and manages log levels via
|
||||
/// [MetadataRepository].
|
||||
class LogService {
|
||||
final LogRepository _logRepository;
|
||||
final DriftStoreRepository _storeRepository;
|
||||
final MetadataRepository _metadataRepository;
|
||||
|
||||
final List<LogMessage> _msgBuffer = [];
|
||||
|
||||
@@ -38,12 +38,12 @@ class LogService {
|
||||
|
||||
static Future<LogService> init({
|
||||
required LogRepository logRepository,
|
||||
required DriftStoreRepository storeRepository,
|
||||
required MetadataRepository metadataRepository,
|
||||
bool shouldBuffer = true,
|
||||
}) async {
|
||||
_instance ??= await create(
|
||||
logRepository: logRepository,
|
||||
storeRepository: storeRepository,
|
||||
metadataRepository: metadataRepository,
|
||||
shouldBuffer: shouldBuffer,
|
||||
);
|
||||
return _instance!;
|
||||
@@ -51,17 +51,17 @@ class LogService {
|
||||
|
||||
static Future<LogService> create({
|
||||
required LogRepository logRepository,
|
||||
required DriftStoreRepository storeRepository,
|
||||
required MetadataRepository metadataRepository,
|
||||
bool shouldBuffer = true,
|
||||
}) async {
|
||||
final instance = LogService._(logRepository, storeRepository, shouldBuffer);
|
||||
final instance = LogService._(logRepository, metadataRepository, shouldBuffer);
|
||||
await logRepository.truncate(limit: kLogTruncateLimit);
|
||||
final level = await instance._storeRepository.tryGet(StoreKey.logLevel) ?? LogLevel.info.index;
|
||||
Logger.root.level = Level.LEVELS.elementAtOrNull(level) ?? Level.INFO;
|
||||
final level = instance._metadataRepository.systemConfig.logLevel;
|
||||
Logger.root.level = Level.LEVELS.elementAtOrNull(level.index) ?? Level.INFO;
|
||||
return instance;
|
||||
}
|
||||
|
||||
LogService._(this._logRepository, this._storeRepository, this._shouldBuffer) {
|
||||
LogService._(this._logRepository, this._metadataRepository, this._shouldBuffer) {
|
||||
_logSubscription = Logger.root.onRecord.listen(_handleLogRecord);
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ class LogService {
|
||||
}
|
||||
|
||||
Future<void> setLogLevel(LogLevel level) async {
|
||||
await _storeRepository.upsert(StoreKey.logLevel, level.index);
|
||||
await _metadataRepository.write(MetadataKey.logLevel, level);
|
||||
Logger.root.level = level.toLevel();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import 'package:immich_mobile/domain/models/ocr.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/ocr.repository.dart';
|
||||
|
||||
class OcrService {
|
||||
final OcrRepository _repository;
|
||||
|
||||
const OcrService(this._repository);
|
||||
|
||||
Future<List<Ocr>?> get(String assetId) {
|
||||
return _repository.get(assetId);
|
||||
}
|
||||
}
|
||||
@@ -312,10 +312,6 @@ class SyncStreamService {
|
||||
return _syncStreamRepository.updateAssetFacesV2(data.cast());
|
||||
case SyncEntityType.assetFaceDeleteV1:
|
||||
return _syncStreamRepository.deleteAssetFacesV1(data.cast());
|
||||
case SyncEntityType.assetOcrV1:
|
||||
return _syncStreamRepository.updateAssetOcrV1(data.cast());
|
||||
case SyncEntityType.assetOcrDeleteV1:
|
||||
return _syncStreamRepository.deleteAssetOcrV1(data.cast());
|
||||
default:
|
||||
_logger.warning("Unknown sync data type: $type");
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
|
||||
|
||||
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_asset_ocr_asset_id ON asset_ocr_entity (asset_id)')
|
||||
class AssetOcrEntity extends Table with DriftDefaultsMixin {
|
||||
const AssetOcrEntity();
|
||||
|
||||
TextColumn get id => text()();
|
||||
|
||||
TextColumn get assetId => text().references(RemoteAssetEntity, #id, onDelete: KeyAction.cascade)();
|
||||
|
||||
RealColumn get x1 => real()();
|
||||
RealColumn get y1 => real()();
|
||||
|
||||
RealColumn get x2 => real()();
|
||||
RealColumn get y2 => real()();
|
||||
|
||||
RealColumn get x3 => real()();
|
||||
RealColumn get y3 => real()();
|
||||
|
||||
RealColumn get x4 => real()();
|
||||
RealColumn get y4 => real()();
|
||||
|
||||
RealColumn get boxScore => real()();
|
||||
RealColumn get textScore => real()();
|
||||
|
||||
TextColumn get recognizedText => text()();
|
||||
|
||||
BoolColumn get isVisible => boolean().withDefault(const Constant(true))();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
|
||||
|
||||
class MetadataEntity extends Table with DriftDefaultsMixin {
|
||||
const MetadataEntity();
|
||||
|
||||
TextColumn get key => text()();
|
||||
|
||||
TextColumn get value => text()();
|
||||
|
||||
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {key};
|
||||
|
||||
@override
|
||||
String get tableName => "metadata";
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
// dart format width=80
|
||||
// ignore_for_file: type=lint
|
||||
import 'package:drift/drift.dart' as i0;
|
||||
import 'package:immich_mobile/infrastructure/entities/metadata.entity.drift.dart'
|
||||
as i1;
|
||||
import 'package:immich_mobile/infrastructure/entities/metadata.entity.dart'
|
||||
as i2;
|
||||
import 'package:drift/src/runtime/query_builder/query_builder.dart' as i3;
|
||||
|
||||
typedef $$MetadataEntityTableCreateCompanionBuilder =
|
||||
i1.MetadataEntityCompanion Function({
|
||||
required String key,
|
||||
required String value,
|
||||
i0.Value<DateTime> updatedAt,
|
||||
});
|
||||
typedef $$MetadataEntityTableUpdateCompanionBuilder =
|
||||
i1.MetadataEntityCompanion Function({
|
||||
i0.Value<String> key,
|
||||
i0.Value<String> value,
|
||||
i0.Value<DateTime> updatedAt,
|
||||
});
|
||||
|
||||
class $$MetadataEntityTableFilterComposer
|
||||
extends i0.Composer<i0.GeneratedDatabase, i1.$MetadataEntityTable> {
|
||||
$$MetadataEntityTableFilterComposer({
|
||||
required super.$db,
|
||||
required super.$table,
|
||||
super.joinBuilder,
|
||||
super.$addJoinBuilderToRootComposer,
|
||||
super.$removeJoinBuilderFromRootComposer,
|
||||
});
|
||||
i0.ColumnFilters<String> get key => $composableBuilder(
|
||||
column: $table.key,
|
||||
builder: (column) => i0.ColumnFilters(column),
|
||||
);
|
||||
|
||||
i0.ColumnFilters<String> get value => $composableBuilder(
|
||||
column: $table.value,
|
||||
builder: (column) => i0.ColumnFilters(column),
|
||||
);
|
||||
|
||||
i0.ColumnFilters<DateTime> get updatedAt => $composableBuilder(
|
||||
column: $table.updatedAt,
|
||||
builder: (column) => i0.ColumnFilters(column),
|
||||
);
|
||||
}
|
||||
|
||||
class $$MetadataEntityTableOrderingComposer
|
||||
extends i0.Composer<i0.GeneratedDatabase, i1.$MetadataEntityTable> {
|
||||
$$MetadataEntityTableOrderingComposer({
|
||||
required super.$db,
|
||||
required super.$table,
|
||||
super.joinBuilder,
|
||||
super.$addJoinBuilderToRootComposer,
|
||||
super.$removeJoinBuilderFromRootComposer,
|
||||
});
|
||||
i0.ColumnOrderings<String> get key => $composableBuilder(
|
||||
column: $table.key,
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
);
|
||||
|
||||
i0.ColumnOrderings<String> get value => $composableBuilder(
|
||||
column: $table.value,
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
);
|
||||
|
||||
i0.ColumnOrderings<DateTime> get updatedAt => $composableBuilder(
|
||||
column: $table.updatedAt,
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
);
|
||||
}
|
||||
|
||||
class $$MetadataEntityTableAnnotationComposer
|
||||
extends i0.Composer<i0.GeneratedDatabase, i1.$MetadataEntityTable> {
|
||||
$$MetadataEntityTableAnnotationComposer({
|
||||
required super.$db,
|
||||
required super.$table,
|
||||
super.joinBuilder,
|
||||
super.$addJoinBuilderToRootComposer,
|
||||
super.$removeJoinBuilderFromRootComposer,
|
||||
});
|
||||
i0.GeneratedColumn<String> get key =>
|
||||
$composableBuilder(column: $table.key, builder: (column) => column);
|
||||
|
||||
i0.GeneratedColumn<String> get value =>
|
||||
$composableBuilder(column: $table.value, builder: (column) => column);
|
||||
|
||||
i0.GeneratedColumn<DateTime> get updatedAt =>
|
||||
$composableBuilder(column: $table.updatedAt, builder: (column) => column);
|
||||
}
|
||||
|
||||
class $$MetadataEntityTableTableManager
|
||||
extends
|
||||
i0.RootTableManager<
|
||||
i0.GeneratedDatabase,
|
||||
i1.$MetadataEntityTable,
|
||||
i1.MetadataEntityData,
|
||||
i1.$$MetadataEntityTableFilterComposer,
|
||||
i1.$$MetadataEntityTableOrderingComposer,
|
||||
i1.$$MetadataEntityTableAnnotationComposer,
|
||||
$$MetadataEntityTableCreateCompanionBuilder,
|
||||
$$MetadataEntityTableUpdateCompanionBuilder,
|
||||
(
|
||||
i1.MetadataEntityData,
|
||||
i0.BaseReferences<
|
||||
i0.GeneratedDatabase,
|
||||
i1.$MetadataEntityTable,
|
||||
i1.MetadataEntityData
|
||||
>,
|
||||
),
|
||||
i1.MetadataEntityData,
|
||||
i0.PrefetchHooks Function()
|
||||
> {
|
||||
$$MetadataEntityTableTableManager(
|
||||
i0.GeneratedDatabase db,
|
||||
i1.$MetadataEntityTable table,
|
||||
) : super(
|
||||
i0.TableManagerState(
|
||||
db: db,
|
||||
table: table,
|
||||
createFilteringComposer: () =>
|
||||
i1.$$MetadataEntityTableFilterComposer($db: db, $table: table),
|
||||
createOrderingComposer: () =>
|
||||
i1.$$MetadataEntityTableOrderingComposer($db: db, $table: table),
|
||||
createComputedFieldComposer: () => i1
|
||||
.$$MetadataEntityTableAnnotationComposer($db: db, $table: table),
|
||||
updateCompanionCallback:
|
||||
({
|
||||
i0.Value<String> key = const i0.Value.absent(),
|
||||
i0.Value<String> value = const i0.Value.absent(),
|
||||
i0.Value<DateTime> updatedAt = const i0.Value.absent(),
|
||||
}) => i1.MetadataEntityCompanion(
|
||||
key: key,
|
||||
value: value,
|
||||
updatedAt: updatedAt,
|
||||
),
|
||||
createCompanionCallback:
|
||||
({
|
||||
required String key,
|
||||
required String value,
|
||||
i0.Value<DateTime> updatedAt = const i0.Value.absent(),
|
||||
}) => i1.MetadataEntityCompanion.insert(
|
||||
key: key,
|
||||
value: value,
|
||||
updatedAt: updatedAt,
|
||||
),
|
||||
withReferenceMapper: (p0) => p0
|
||||
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
|
||||
.toList(),
|
||||
prefetchHooksCallback: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
typedef $$MetadataEntityTableProcessedTableManager =
|
||||
i0.ProcessedTableManager<
|
||||
i0.GeneratedDatabase,
|
||||
i1.$MetadataEntityTable,
|
||||
i1.MetadataEntityData,
|
||||
i1.$$MetadataEntityTableFilterComposer,
|
||||
i1.$$MetadataEntityTableOrderingComposer,
|
||||
i1.$$MetadataEntityTableAnnotationComposer,
|
||||
$$MetadataEntityTableCreateCompanionBuilder,
|
||||
$$MetadataEntityTableUpdateCompanionBuilder,
|
||||
(
|
||||
i1.MetadataEntityData,
|
||||
i0.BaseReferences<
|
||||
i0.GeneratedDatabase,
|
||||
i1.$MetadataEntityTable,
|
||||
i1.MetadataEntityData
|
||||
>,
|
||||
),
|
||||
i1.MetadataEntityData,
|
||||
i0.PrefetchHooks Function()
|
||||
>;
|
||||
|
||||
class $MetadataEntityTable extends i2.MetadataEntity
|
||||
with i0.TableInfo<$MetadataEntityTable, i1.MetadataEntityData> {
|
||||
@override
|
||||
final i0.GeneratedDatabase attachedDatabase;
|
||||
final String? _alias;
|
||||
$MetadataEntityTable(this.attachedDatabase, [this._alias]);
|
||||
static const i0.VerificationMeta _keyMeta = const i0.VerificationMeta('key');
|
||||
@override
|
||||
late final i0.GeneratedColumn<String> key = i0.GeneratedColumn<String>(
|
||||
'key',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i0.DriftSqlType.string,
|
||||
requiredDuringInsert: true,
|
||||
);
|
||||
static const i0.VerificationMeta _valueMeta = const i0.VerificationMeta(
|
||||
'value',
|
||||
);
|
||||
@override
|
||||
late final i0.GeneratedColumn<String> value = i0.GeneratedColumn<String>(
|
||||
'value',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i0.DriftSqlType.string,
|
||||
requiredDuringInsert: true,
|
||||
);
|
||||
static const i0.VerificationMeta _updatedAtMeta = const i0.VerificationMeta(
|
||||
'updatedAt',
|
||||
);
|
||||
@override
|
||||
late final i0.GeneratedColumn<DateTime> updatedAt =
|
||||
i0.GeneratedColumn<DateTime>(
|
||||
'updated_at',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i0.DriftSqlType.dateTime,
|
||||
requiredDuringInsert: false,
|
||||
defaultValue: i3.currentDateAndTime,
|
||||
);
|
||||
@override
|
||||
List<i0.GeneratedColumn> get $columns => [key, value, updatedAt];
|
||||
@override
|
||||
String get aliasedName => _alias ?? actualTableName;
|
||||
@override
|
||||
String get actualTableName => $name;
|
||||
static const String $name = 'metadata';
|
||||
@override
|
||||
i0.VerificationContext validateIntegrity(
|
||||
i0.Insertable<i1.MetadataEntityData> instance, {
|
||||
bool isInserting = false,
|
||||
}) {
|
||||
final context = i0.VerificationContext();
|
||||
final data = instance.toColumns(true);
|
||||
if (data.containsKey('key')) {
|
||||
context.handle(
|
||||
_keyMeta,
|
||||
key.isAcceptableOrUnknown(data['key']!, _keyMeta),
|
||||
);
|
||||
} else if (isInserting) {
|
||||
context.missing(_keyMeta);
|
||||
}
|
||||
if (data.containsKey('value')) {
|
||||
context.handle(
|
||||
_valueMeta,
|
||||
value.isAcceptableOrUnknown(data['value']!, _valueMeta),
|
||||
);
|
||||
} else if (isInserting) {
|
||||
context.missing(_valueMeta);
|
||||
}
|
||||
if (data.containsKey('updated_at')) {
|
||||
context.handle(
|
||||
_updatedAtMeta,
|
||||
updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta),
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
@override
|
||||
Set<i0.GeneratedColumn> get $primaryKey => {key};
|
||||
@override
|
||||
i1.MetadataEntityData map(Map<String, dynamic> data, {String? tablePrefix}) {
|
||||
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
|
||||
return i1.MetadataEntityData(
|
||||
key: attachedDatabase.typeMapping.read(
|
||||
i0.DriftSqlType.string,
|
||||
data['${effectivePrefix}key'],
|
||||
)!,
|
||||
value: attachedDatabase.typeMapping.read(
|
||||
i0.DriftSqlType.string,
|
||||
data['${effectivePrefix}value'],
|
||||
)!,
|
||||
updatedAt: attachedDatabase.typeMapping.read(
|
||||
i0.DriftSqlType.dateTime,
|
||||
data['${effectivePrefix}updated_at'],
|
||||
)!,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
$MetadataEntityTable createAlias(String alias) {
|
||||
return $MetadataEntityTable(attachedDatabase, alias);
|
||||
}
|
||||
|
||||
@override
|
||||
bool get withoutRowId => true;
|
||||
@override
|
||||
bool get isStrict => true;
|
||||
}
|
||||
|
||||
class MetadataEntityData extends i0.DataClass
|
||||
implements i0.Insertable<i1.MetadataEntityData> {
|
||||
final String key;
|
||||
final String value;
|
||||
final DateTime updatedAt;
|
||||
const MetadataEntityData({
|
||||
required this.key,
|
||||
required this.value,
|
||||
required this.updatedAt,
|
||||
});
|
||||
@override
|
||||
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
|
||||
final map = <String, i0.Expression>{};
|
||||
map['key'] = i0.Variable<String>(key);
|
||||
map['value'] = i0.Variable<String>(value);
|
||||
map['updated_at'] = i0.Variable<DateTime>(updatedAt);
|
||||
return map;
|
||||
}
|
||||
|
||||
factory MetadataEntityData.fromJson(
|
||||
Map<String, dynamic> json, {
|
||||
i0.ValueSerializer? serializer,
|
||||
}) {
|
||||
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
|
||||
return MetadataEntityData(
|
||||
key: serializer.fromJson<String>(json['key']),
|
||||
value: serializer.fromJson<String>(json['value']),
|
||||
updatedAt: serializer.fromJson<DateTime>(json['updatedAt']),
|
||||
);
|
||||
}
|
||||
@override
|
||||
Map<String, dynamic> toJson({i0.ValueSerializer? serializer}) {
|
||||
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
|
||||
return <String, dynamic>{
|
||||
'key': serializer.toJson<String>(key),
|
||||
'value': serializer.toJson<String>(value),
|
||||
'updatedAt': serializer.toJson<DateTime>(updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
i1.MetadataEntityData copyWith({
|
||||
String? key,
|
||||
String? value,
|
||||
DateTime? updatedAt,
|
||||
}) => i1.MetadataEntityData(
|
||||
key: key ?? this.key,
|
||||
value: value ?? this.value,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
);
|
||||
MetadataEntityData copyWithCompanion(i1.MetadataEntityCompanion data) {
|
||||
return MetadataEntityData(
|
||||
key: data.key.present ? data.key.value : this.key,
|
||||
value: data.value.present ? data.value.value : this.value,
|
||||
updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return (StringBuffer('MetadataEntityData(')
|
||||
..write('key: $key, ')
|
||||
..write('value: $value, ')
|
||||
..write('updatedAt: $updatedAt')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(key, value, updatedAt);
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
(other is i1.MetadataEntityData &&
|
||||
other.key == this.key &&
|
||||
other.value == this.value &&
|
||||
other.updatedAt == this.updatedAt);
|
||||
}
|
||||
|
||||
class MetadataEntityCompanion
|
||||
extends i0.UpdateCompanion<i1.MetadataEntityData> {
|
||||
final i0.Value<String> key;
|
||||
final i0.Value<String> value;
|
||||
final i0.Value<DateTime> updatedAt;
|
||||
const MetadataEntityCompanion({
|
||||
this.key = const i0.Value.absent(),
|
||||
this.value = const i0.Value.absent(),
|
||||
this.updatedAt = const i0.Value.absent(),
|
||||
});
|
||||
MetadataEntityCompanion.insert({
|
||||
required String key,
|
||||
required String value,
|
||||
this.updatedAt = const i0.Value.absent(),
|
||||
}) : key = i0.Value(key),
|
||||
value = i0.Value(value);
|
||||
static i0.Insertable<i1.MetadataEntityData> custom({
|
||||
i0.Expression<String>? key,
|
||||
i0.Expression<String>? value,
|
||||
i0.Expression<DateTime>? updatedAt,
|
||||
}) {
|
||||
return i0.RawValuesInsertable({
|
||||
if (key != null) 'key': key,
|
||||
if (value != null) 'value': value,
|
||||
if (updatedAt != null) 'updated_at': updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
i1.MetadataEntityCompanion copyWith({
|
||||
i0.Value<String>? key,
|
||||
i0.Value<String>? value,
|
||||
i0.Value<DateTime>? updatedAt,
|
||||
}) {
|
||||
return i1.MetadataEntityCompanion(
|
||||
key: key ?? this.key,
|
||||
value: value ?? this.value,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
|
||||
final map = <String, i0.Expression>{};
|
||||
if (key.present) {
|
||||
map['key'] = i0.Variable<String>(key.value);
|
||||
}
|
||||
if (value.present) {
|
||||
map['value'] = i0.Variable<String>(value.value);
|
||||
}
|
||||
if (updatedAt.present) {
|
||||
map['updated_at'] = i0.Variable<DateTime>(updatedAt.value);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return (StringBuffer('MetadataEntityCompanion(')
|
||||
..write('key: $key, ')
|
||||
..write('value: $value, ')
|
||||
..write('updatedAt: $updatedAt')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import 'package:drift_flutter/drift_flutter.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/asset_face.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/asset_ocr.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/auth_user.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart';
|
||||
@@ -14,6 +13,7 @@ import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/memory.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/memory_asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/metadata.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/partner.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/person.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_album.entity.dart';
|
||||
@@ -54,7 +54,7 @@ import 'package:immich_mobile/infrastructure/repositories/db.repository.steps.da
|
||||
StoreEntity,
|
||||
TrashedLocalAssetEntity,
|
||||
AssetEditEntity,
|
||||
AssetOcrEntity,
|
||||
MetadataEntity,
|
||||
],
|
||||
include: {'package:immich_mobile/infrastructure/entities/merged_asset.drift'},
|
||||
)
|
||||
@@ -253,7 +253,7 @@ class Drift extends $Drift {
|
||||
await m.alterTable(TableMigration(v24.remoteAlbumEntity));
|
||||
},
|
||||
from24To25: (m, v25) async {
|
||||
await m.create(v25.assetOcrEntity);
|
||||
await m.createTable(v25.metadata);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
+5
-12
@@ -43,7 +43,7 @@ import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity
|
||||
as i20;
|
||||
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.dart'
|
||||
as i21;
|
||||
import 'package:immich_mobile/infrastructure/entities/asset_ocr.entity.drift.dart'
|
||||
import 'package:immich_mobile/infrastructure/entities/metadata.entity.drift.dart'
|
||||
as i22;
|
||||
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
|
||||
as i23;
|
||||
@@ -91,7 +91,7 @@ abstract class $Drift extends i0.GeneratedDatabase {
|
||||
.$TrashedLocalAssetEntityTable(this);
|
||||
late final i21.$AssetEditEntityTable assetEditEntity = i21
|
||||
.$AssetEditEntityTable(this);
|
||||
late final i22.$AssetOcrEntityTable assetOcrEntity = i22.$AssetOcrEntityTable(
|
||||
late final i22.$MetadataEntityTable metadataEntity = i22.$MetadataEntityTable(
|
||||
this,
|
||||
);
|
||||
i23.MergedAssetDrift get mergedAssetDrift => i24.ReadDatabaseContainer(
|
||||
@@ -134,7 +134,7 @@ abstract class $Drift extends i0.GeneratedDatabase {
|
||||
storeEntity,
|
||||
trashedLocalAssetEntity,
|
||||
assetEditEntity,
|
||||
assetOcrEntity,
|
||||
metadataEntity,
|
||||
i10.idxPartnerSharedWithId,
|
||||
i11.idxLatLng,
|
||||
i12.idxRemoteAlbumAssetAlbumAsset,
|
||||
@@ -334,13 +334,6 @@ abstract class $Drift extends i0.GeneratedDatabase {
|
||||
),
|
||||
result: [i0.TableUpdate('asset_edit_entity', kind: i0.UpdateKind.delete)],
|
||||
),
|
||||
i0.WritePropagation(
|
||||
on: i0.TableUpdateQuery.onTableName(
|
||||
'remote_asset_entity',
|
||||
limitUpdateKind: i0.UpdateKind.delete,
|
||||
),
|
||||
result: [i0.TableUpdate('asset_ocr_entity', kind: i0.UpdateKind.delete)],
|
||||
),
|
||||
]);
|
||||
@override
|
||||
i0.DriftDatabaseOptions get options =>
|
||||
@@ -402,6 +395,6 @@ class $DriftManager {
|
||||
);
|
||||
i21.$$AssetEditEntityTableTableManager get assetEditEntity =>
|
||||
i21.$$AssetEditEntityTableTableManager(_db, _db.assetEditEntity);
|
||||
i22.$$AssetOcrEntityTableTableManager get assetOcrEntity =>
|
||||
i22.$$AssetOcrEntityTableTableManager(_db, _db.assetOcrEntity);
|
||||
i22.$$MetadataEntityTableTableManager get metadataEntity =>
|
||||
i22.$$MetadataEntityTableTableManager(_db, _db.metadataEntity);
|
||||
}
|
||||
|
||||
+21
-130
@@ -12411,7 +12411,7 @@ final class Schema25 extends i0.VersionedSchema {
|
||||
storeEntity,
|
||||
trashedLocalAssetEntity,
|
||||
assetEditEntity,
|
||||
assetOcrEntity,
|
||||
metadata,
|
||||
idxPartnerSharedWithId,
|
||||
idxLatLng,
|
||||
idxRemoteAlbumAssetAlbumAsset,
|
||||
@@ -12864,28 +12864,13 @@ final class Schema25 extends i0.VersionedSchema {
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape49 assetOcrEntity = Shape49(
|
||||
late final Shape49 metadata = Shape49(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'asset_ocr_entity',
|
||||
entityName: 'metadata',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_159,
|
||||
_column_210,
|
||||
_column_211,
|
||||
_column_212,
|
||||
_column_213,
|
||||
_column_214,
|
||||
_column_215,
|
||||
_column_216,
|
||||
_column_217,
|
||||
_column_218,
|
||||
_column_219,
|
||||
_column_220,
|
||||
_column_201,
|
||||
],
|
||||
tableConstraints: ['PRIMARY KEY("key")'],
|
||||
columns: [_column_210, _column_211, _column_115],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
@@ -12934,119 +12919,25 @@ final class Schema25 extends i0.VersionedSchema {
|
||||
|
||||
class Shape49 extends i0.VersionedTable {
|
||||
Shape49({required super.source, required super.alias}) : super.aliased();
|
||||
i1.GeneratedColumn<String> get id =>
|
||||
columnsByName['id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get assetId =>
|
||||
columnsByName['asset_id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<double> get x1 =>
|
||||
columnsByName['x1']! as i1.GeneratedColumn<double>;
|
||||
i1.GeneratedColumn<double> get y1 =>
|
||||
columnsByName['y1']! as i1.GeneratedColumn<double>;
|
||||
i1.GeneratedColumn<double> get x2 =>
|
||||
columnsByName['x2']! as i1.GeneratedColumn<double>;
|
||||
i1.GeneratedColumn<double> get y2 =>
|
||||
columnsByName['y2']! as i1.GeneratedColumn<double>;
|
||||
i1.GeneratedColumn<double> get x3 =>
|
||||
columnsByName['x3']! as i1.GeneratedColumn<double>;
|
||||
i1.GeneratedColumn<double> get y3 =>
|
||||
columnsByName['y3']! as i1.GeneratedColumn<double>;
|
||||
i1.GeneratedColumn<double> get x4 =>
|
||||
columnsByName['x4']! as i1.GeneratedColumn<double>;
|
||||
i1.GeneratedColumn<double> get y4 =>
|
||||
columnsByName['y4']! as i1.GeneratedColumn<double>;
|
||||
i1.GeneratedColumn<double> get boxScore =>
|
||||
columnsByName['box_score']! as i1.GeneratedColumn<double>;
|
||||
i1.GeneratedColumn<double> get textScore =>
|
||||
columnsByName['text_score']! as i1.GeneratedColumn<double>;
|
||||
i1.GeneratedColumn<String> get recognizedText =>
|
||||
columnsByName['recognized_text']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<int> get isVisible =>
|
||||
columnsByName['is_visible']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get key =>
|
||||
columnsByName['key']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get value =>
|
||||
columnsByName['value']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get updatedAt =>
|
||||
columnsByName['updated_at']! as i1.GeneratedColumn<String>;
|
||||
}
|
||||
|
||||
i1.GeneratedColumn<double> _column_210(String aliasedName) =>
|
||||
i1.GeneratedColumn<double>(
|
||||
'x1',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.double,
|
||||
$customConstraints: 'NOT NULL',
|
||||
);
|
||||
i1.GeneratedColumn<double> _column_211(String aliasedName) =>
|
||||
i1.GeneratedColumn<double>(
|
||||
'y1',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.double,
|
||||
$customConstraints: 'NOT NULL',
|
||||
);
|
||||
i1.GeneratedColumn<double> _column_212(String aliasedName) =>
|
||||
i1.GeneratedColumn<double>(
|
||||
'x2',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.double,
|
||||
$customConstraints: 'NOT NULL',
|
||||
);
|
||||
i1.GeneratedColumn<double> _column_213(String aliasedName) =>
|
||||
i1.GeneratedColumn<double>(
|
||||
'y2',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.double,
|
||||
$customConstraints: 'NOT NULL',
|
||||
);
|
||||
i1.GeneratedColumn<double> _column_214(String aliasedName) =>
|
||||
i1.GeneratedColumn<double>(
|
||||
'x3',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.double,
|
||||
$customConstraints: 'NOT NULL',
|
||||
);
|
||||
i1.GeneratedColumn<double> _column_215(String aliasedName) =>
|
||||
i1.GeneratedColumn<double>(
|
||||
'y3',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.double,
|
||||
$customConstraints: 'NOT NULL',
|
||||
);
|
||||
i1.GeneratedColumn<double> _column_216(String aliasedName) =>
|
||||
i1.GeneratedColumn<double>(
|
||||
'x4',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.double,
|
||||
$customConstraints: 'NOT NULL',
|
||||
);
|
||||
i1.GeneratedColumn<double> _column_217(String aliasedName) =>
|
||||
i1.GeneratedColumn<double>(
|
||||
'y4',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.double,
|
||||
$customConstraints: 'NOT NULL',
|
||||
);
|
||||
i1.GeneratedColumn<double> _column_218(String aliasedName) =>
|
||||
i1.GeneratedColumn<double>(
|
||||
'box_score',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.double,
|
||||
$customConstraints: 'NOT NULL',
|
||||
);
|
||||
i1.GeneratedColumn<double> _column_219(String aliasedName) =>
|
||||
i1.GeneratedColumn<double>(
|
||||
'text_score',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.double,
|
||||
$customConstraints: 'NOT NULL',
|
||||
);
|
||||
i1.GeneratedColumn<String> _column_220(String aliasedName) =>
|
||||
i1.GeneratedColumn<String> _column_210(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>(
|
||||
'recognized_text',
|
||||
'key',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.string,
|
||||
$customConstraints: 'NOT NULL',
|
||||
);
|
||||
i1.GeneratedColumn<String> _column_211(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>(
|
||||
'value',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.string,
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/domain/models/config/app_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/system_config.dart';
|
||||
import 'package:immich_mobile/domain/models/metadata_key.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/metadata.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
|
||||
class MetadataRepository extends DriftDatabaseRepository {
|
||||
final Drift _db;
|
||||
final Map<MetadataKey, Object> _cache = {};
|
||||
|
||||
MetadataRepository._(this._db) : super(_db);
|
||||
|
||||
static MetadataRepository? _instance;
|
||||
|
||||
static MetadataRepository get instance {
|
||||
final instance = _instance;
|
||||
if (instance == null) {
|
||||
throw StateError('MetadataRepository not initialized. Call ensureInitialized() first');
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
AppConfig _appConfig = const .new();
|
||||
AppConfig get appConfig => _appConfig;
|
||||
|
||||
SystemConfig _systemConfig = const .new();
|
||||
SystemConfig get systemConfig => _systemConfig;
|
||||
|
||||
static Future<MetadataRepository> ensureInitialized(Drift db) async {
|
||||
if (_instance == null) {
|
||||
final instance = MetadataRepository._(db);
|
||||
await instance._hydrate();
|
||||
_instance = instance;
|
||||
}
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
static Future<void> refresh() async {
|
||||
instance._cache.clear();
|
||||
instance._appConfig = const .new();
|
||||
instance._systemConfig = const .new();
|
||||
await instance._hydrate();
|
||||
}
|
||||
|
||||
Future<void> _hydrate() async => _hydrateCache(await _db.select(_db.metadataEntity).get());
|
||||
|
||||
T _read<T extends Object>(MetadataKey<T> key) => (_cache[key] as T?) ?? key.defaultValue;
|
||||
|
||||
Future<void> write<T extends Object>(MetadataKey<T> key, T value) async {
|
||||
if (_read(key) == value) return;
|
||||
|
||||
await _db
|
||||
.into(_db.metadataEntity)
|
||||
.insertOnConflictUpdate(
|
||||
MetadataEntityCompanion.insert(key: key.key, value: key.encode(value), updatedAt: Value(DateTime.now())),
|
||||
);
|
||||
_updateCache(key, value);
|
||||
}
|
||||
|
||||
Future<void> delete<T extends Object>(MetadataKey<T> key) async {
|
||||
await (_db.delete(_db.metadataEntity)..where((t) => t.key.equals(key.key))).go();
|
||||
_updateCache(key, key.defaultValue);
|
||||
}
|
||||
|
||||
Stream<AppConfig> watchAppConfig() => _watchDomain(MetadataDomain.appConfig).distinct();
|
||||
|
||||
Stream<SystemConfig> watchSystemConfig() => _watchDomain(MetadataDomain.systemConfig).distinct();
|
||||
|
||||
Stream<T> _watchDomain<T extends Object>(MetadataDomain<T> domain) {
|
||||
final query = _db.select(_db.metadataEntity)..where((t) => t.key.like('${domain.prefix}.%'));
|
||||
return query.watch().map((rows) {
|
||||
_hydrateCache(rows);
|
||||
return domain.config(this);
|
||||
});
|
||||
}
|
||||
|
||||
void _hydrateCache(List<MetadataEntityData> rows) {
|
||||
final keyMap = MetadataKey.asKeyMap();
|
||||
for (final row in rows) {
|
||||
final key = keyMap[row.key];
|
||||
if (key == null) continue;
|
||||
_updateCache(key, key.decode(row.value));
|
||||
}
|
||||
}
|
||||
|
||||
void _updateCache<T extends Object>(MetadataKey<T> key, T value) {
|
||||
if (_cache[key] == value) return;
|
||||
_cache[key] = value;
|
||||
key.domain.rebuild(this);
|
||||
}
|
||||
}
|
||||
|
||||
extension<T extends Object> on MetadataDomain<T> {
|
||||
T config(MetadataRepository repo) => switch (this) {
|
||||
.appConfig => repo._appConfig as T,
|
||||
.systemConfig => repo._systemConfig as T,
|
||||
};
|
||||
|
||||
void rebuild(MetadataRepository repo) {
|
||||
switch (this) {
|
||||
case .appConfig:
|
||||
repo._appConfig = .new(theme: .new(mode: repo._read(.themeMode)));
|
||||
case .systemConfig:
|
||||
repo._systemConfig = .new(logLevel: repo._read(.logLevel));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import 'package:immich_mobile/domain/models/ocr.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/asset_ocr.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
|
||||
class OcrRepository extends DriftDatabaseRepository {
|
||||
final Drift _db;
|
||||
const OcrRepository(this._db) : super(_db);
|
||||
|
||||
Future<List<Ocr>> get(String assetId) async {
|
||||
final query = _db.select(_db.assetOcrEntity)
|
||||
..where((row) => row.assetId.equals(assetId) & row.isVisible.equals(true));
|
||||
|
||||
final result = await query.get();
|
||||
return result.map((e) => e.toDto()).toList();
|
||||
}
|
||||
}
|
||||
|
||||
extension on AssetOcrEntityData {
|
||||
Ocr toDto() {
|
||||
return Ocr(
|
||||
id: id,
|
||||
assetId: assetId,
|
||||
x1: x1,
|
||||
y1: y1,
|
||||
x2: x2,
|
||||
y2: y2,
|
||||
x3: x3,
|
||||
y3: y3,
|
||||
x4: x4,
|
||||
y4: y4,
|
||||
boxScore: boxScore,
|
||||
textScore: textScore,
|
||||
text: recognizedText,
|
||||
isVisible: isVisible,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -76,7 +76,6 @@ class SyncApiRepository {
|
||||
serverVersion >= const SemVer(major: 2, minor: 6, patch: 0)
|
||||
? SyncRequestType.assetFacesV2
|
||||
: SyncRequestType.assetFacesV1,
|
||||
if (serverVersion >= const SemVer(major: 3, minor: 0, patch: 0)) SyncRequestType.assetOcrV1,
|
||||
],
|
||||
reset: shouldReset,
|
||||
).toJson(),
|
||||
@@ -211,8 +210,6 @@ const _kResponseMap = <SyncEntityType, Function(Object)>{
|
||||
SyncEntityType.assetFaceV1: SyncAssetFaceV1.fromJson,
|
||||
SyncEntityType.assetFaceV2: SyncAssetFaceV2.fromJson,
|
||||
SyncEntityType.assetFaceDeleteV1: SyncAssetFaceDeleteV1.fromJson,
|
||||
SyncEntityType.assetOcrV1: SyncAssetOcrV1.fromJson,
|
||||
SyncEntityType.assetOcrDeleteV1: SyncAssetOcrDeleteV1.fromJson,
|
||||
SyncEntityType.syncCompleteV1: _SyncEmptyDto.fromJson,
|
||||
};
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import 'package:immich_mobile/domain/models/user_metadata.model.dart';
|
||||
import 'package:immich_mobile/extensions/string_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/asset_face.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/asset_ocr.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/auth_user.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/memory.entity.drift.dart';
|
||||
@@ -63,7 +62,6 @@ class SyncStreamRepository extends DriftDatabaseRepository {
|
||||
await _db.userMetadataEntity.deleteAll();
|
||||
await _db.remoteAssetCloudIdEntity.deleteAll();
|
||||
await _db.assetEditEntity.deleteAll();
|
||||
await _db.assetOcrEntity.deleteAll();
|
||||
});
|
||||
await _db.customStatement('PRAGMA foreign_keys = ON');
|
||||
});
|
||||
@@ -837,52 +835,6 @@ class SyncStreamRepository extends DriftDatabaseRepository {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateAssetOcrV1(Iterable<SyncAssetOcrV1> data) async {
|
||||
try {
|
||||
await _db.batch((batch) {
|
||||
for (final assetOcr in data) {
|
||||
final companion = AssetOcrEntityCompanion(
|
||||
assetId: Value(assetOcr.assetId),
|
||||
recognizedText: Value(assetOcr.text),
|
||||
x1: Value(assetOcr.x1),
|
||||
y1: Value(assetOcr.y1),
|
||||
x2: Value(assetOcr.x2),
|
||||
y2: Value(assetOcr.y2),
|
||||
x3: Value(assetOcr.x3),
|
||||
y3: Value(assetOcr.y3),
|
||||
x4: Value(assetOcr.x4),
|
||||
y4: Value(assetOcr.y4),
|
||||
boxScore: Value(assetOcr.boxScore),
|
||||
textScore: Value(assetOcr.textScore),
|
||||
isVisible: Value(assetOcr.isVisible),
|
||||
);
|
||||
|
||||
batch.insert(
|
||||
_db.assetOcrEntity,
|
||||
companion.copyWith(id: Value(assetOcr.id)),
|
||||
onConflict: DoUpdate((_) => companion),
|
||||
);
|
||||
}
|
||||
});
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Error: updateAssetOcrV1', error, stack);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteAssetOcrV1(Iterable<SyncAssetOcrDeleteV1> data) async {
|
||||
try {
|
||||
await _db.batch((batch) {
|
||||
for (final assetOcr in data) {
|
||||
batch.deleteWhere(_db.assetOcrEntity, (row) => row.id.equals(assetOcr.id));
|
||||
}
|
||||
});
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Error: deleteAssetOcrV1', error, stack);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> pruneAssets() async {
|
||||
try {
|
||||
await _db.transaction(() async {
|
||||
|
||||
@@ -53,7 +53,7 @@ void main() async {
|
||||
await initApp();
|
||||
// Warm-up isolate pool for worker manager
|
||||
await workerManagerPatch.init(dynamicSpawning: true, isolatesCount: max(Platform.numberOfProcessors - 1, 5));
|
||||
await migrateDatabaseIfNeeded();
|
||||
await migrateDatabaseIfNeeded(drift);
|
||||
|
||||
runApp(ProviderScope(overrides: [driftProvider.overrideWith(driftOverride(drift))], child: const MainWidget()));
|
||||
} catch (error, stack) {
|
||||
|
||||
+10
-1
@@ -22,6 +22,7 @@ class TechnicalDetails extends ConsumerWidget {
|
||||
final exifInfo = this.exifInfo;
|
||||
final cameraTitle = _getCameraInfoTitle(exifInfo);
|
||||
final lensTitle = exifInfo?.lens != null && exifInfo!.lens!.isNotEmpty ? exifInfo.lens : null;
|
||||
final lensSubtitle = _getLensInfoSubtitle(exifInfo);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
@@ -46,9 +47,16 @@ class TechnicalDetails extends ConsumerWidget {
|
||||
title: lensTitle,
|
||||
titleStyle: context.textTheme.labelLarge,
|
||||
leading: Icon(Icons.camera_outlined, size: 24, color: context.textTheme.labelLarge?.color),
|
||||
subtitle: _getLensInfoSubtitle(exifInfo),
|
||||
subtitle: lensSubtitle,
|
||||
subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
),
|
||||
] else if (lensSubtitle != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
SheetTile(
|
||||
title: lensSubtitle,
|
||||
titleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
leading: Icon(Icons.camera_outlined, size: 24, color: context.textTheme.labelLarge?.color),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
@@ -123,6 +131,7 @@ class TechnicalDetails extends ConsumerWidget {
|
||||
if (exifInfo == null) return null;
|
||||
final fNumber = exifInfo.fNumber.isNotEmpty ? 'ƒ/${exifInfo.fNumber}' : null;
|
||||
final focalLength = exifInfo.focalLength.isNotEmpty ? '${exifInfo.focalLength} mm' : null;
|
||||
if (fNumber == null && focalLength == null) return null;
|
||||
return [fNumber, focalLength].where((spec) => spec != null && spec.isNotEmpty).join(_kSeparator);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import 'package:immich_mobile/extensions/scroll_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/ocr_overlay.widget.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||
@@ -358,7 +357,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
_showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
|
||||
final stackIndex = ref.watch(assetViewerProvider.select((s) => s.stackIndex));
|
||||
final isPlayingMotionVideo = ref.watch(isPlayingMotionVideoProvider);
|
||||
final showingOcr = ref.watch(assetViewerProvider.select((s) => s.showingOcr));
|
||||
|
||||
final asset = ref.read(timelineServiceProvider).getAssetSafe(widget.index);
|
||||
if (asset == null) {
|
||||
@@ -408,15 +406,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
isPlayingMotionVideo: isPlayingMotionVideo,
|
||||
),
|
||||
),
|
||||
if (showingOcr && displayAsset.width != null && displayAsset.height != null)
|
||||
Positioned.fill(
|
||||
child: OcrOverlay(
|
||||
asset: displayAsset,
|
||||
imageSize: Size(displayAsset.width!.toDouble(), displayAsset.height!.toDouble()),
|
||||
viewportSize: Size(viewportWidth, viewportHeight),
|
||||
controller: _viewController,
|
||||
),
|
||||
),
|
||||
IgnorePointer(
|
||||
ignoring: !_showingDetails,
|
||||
child: Column(
|
||||
|
||||
@@ -1,341 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/ocr.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/ocr.provider.dart';
|
||||
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
|
||||
|
||||
class OcrOverlay extends ConsumerStatefulWidget {
|
||||
final BaseAsset asset;
|
||||
final Size imageSize;
|
||||
final Size viewportSize;
|
||||
final PhotoViewControllerBase? controller;
|
||||
|
||||
const OcrOverlay({
|
||||
super.key,
|
||||
required this.asset,
|
||||
required this.imageSize,
|
||||
required this.viewportSize,
|
||||
this.controller,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<OcrOverlay> createState() => _OcrOverlayState();
|
||||
}
|
||||
|
||||
class _OcrOverlayState extends ConsumerState<OcrOverlay> {
|
||||
int? _selectedBoxIndex;
|
||||
|
||||
// Current transform read from the PhotoView controller.
|
||||
// Null until the controller has emitted at least one real event or until
|
||||
// we can seed a reliable value from controller.value on init.
|
||||
PhotoViewControllerValue? _controllerValue;
|
||||
StreamSubscription<PhotoViewControllerValue>? _controllerSub;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_attachController(widget.controller);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(OcrOverlay oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.controller != widget.controller) {
|
||||
_detachController();
|
||||
_attachController(widget.controller);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_detachController();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _attachController(PhotoViewControllerBase? controller) {
|
||||
if (controller == null) return;
|
||||
|
||||
// Seed with the current value only when scaleBoundaries is already set.
|
||||
// Before the image finishes loading, PhotoView uses childSize = outerSize
|
||||
// (viewport) as a placeholder, which sets scale = 1.0. That placeholder
|
||||
// is wrong for any image that doesn't exactly fill the viewport.
|
||||
// Once scaleBoundaries is set the value is trustworthy (the image has rendered
|
||||
// at least one frame and setScaleInvisibly has been called with the real
|
||||
// initial/zoomed scale).
|
||||
if (controller.scaleBoundaries != null) {
|
||||
_controllerValue = controller.value;
|
||||
}
|
||||
|
||||
_controllerSub = controller.outputStateStream.listen((value) {
|
||||
if (mounted) setState(() => _controllerValue = value);
|
||||
});
|
||||
}
|
||||
|
||||
void _detachController() {
|
||||
_controllerSub?.cancel();
|
||||
_controllerSub = null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.asset is! RemoteAsset) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final ocrData = ref.watch(ocrAssetProvider((widget.asset as RemoteAsset).id));
|
||||
|
||||
return ocrData.when(
|
||||
data: (data) {
|
||||
if (data == null || data.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return _OcrBoxes(
|
||||
ocrData: data,
|
||||
controller: widget.controller,
|
||||
imageSize: widget.imageSize,
|
||||
viewportSize: widget.viewportSize,
|
||||
controllerValue: _controllerValue,
|
||||
selectedBoxIndex: _selectedBoxIndex,
|
||||
onSelectionChanged: (index) => setState(() => _selectedBoxIndex = index),
|
||||
);
|
||||
},
|
||||
loading: () => const SizedBox.shrink(),
|
||||
error: (_, __) => const SizedBox.shrink(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OcrBoxes extends StatelessWidget {
|
||||
final List<Ocr> ocrData;
|
||||
final PhotoViewControllerBase? controller;
|
||||
final Size imageSize;
|
||||
final Size viewportSize;
|
||||
final PhotoViewControllerValue? controllerValue;
|
||||
final int? selectedBoxIndex;
|
||||
final ValueChanged<int?> onSelectionChanged;
|
||||
|
||||
const _OcrBoxes({
|
||||
required this.ocrData,
|
||||
required this.controller,
|
||||
required this.imageSize,
|
||||
required this.viewportSize,
|
||||
required this.controllerValue,
|
||||
required this.selectedBoxIndex,
|
||||
required this.onSelectionChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Use the actual decoded image size from PhotoView's scaleBoundaries when
|
||||
// available. The image provider may serve a downscaled preview (e.g. Immich
|
||||
// serves a ~1440px preview for large originals), so the decoded dimensions
|
||||
// can differ significantly from the stored asset dimensions. Using the wrong
|
||||
// size would scale every coordinate by the ratio between the two resolutions.
|
||||
final resolvedImageSize = controller?.scaleBoundaries?.childSize ?? imageSize;
|
||||
|
||||
final scale =
|
||||
controllerValue?.scale ??
|
||||
math.min(viewportSize.width / resolvedImageSize.width, viewportSize.height / resolvedImageSize.height);
|
||||
final position = controllerValue?.position ?? Offset.zero;
|
||||
|
||||
final imageWidth = resolvedImageSize.width;
|
||||
final imageHeight = resolvedImageSize.height;
|
||||
final viewportWidth = viewportSize.width;
|
||||
final viewportHeight = viewportSize.height;
|
||||
|
||||
// Image center in viewport space, accounting for pan
|
||||
final cx = viewportWidth / 2 + position.dx;
|
||||
final cy = viewportHeight / 2 + position.dy;
|
||||
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: () => onSelectionChanged(null),
|
||||
child: ClipRect(
|
||||
child: Stack(
|
||||
children: [
|
||||
// Fills the viewport so taps outside boxes deselect
|
||||
SizedBox(width: viewportWidth, height: viewportHeight),
|
||||
...ocrData.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final ocr = entry.value;
|
||||
|
||||
// Map normalized image coords (0–1) to viewport space
|
||||
final x1 = cx + (ocr.x1 - 0.5) * imageWidth * scale;
|
||||
final y1 = cy + (ocr.y1 - 0.5) * imageHeight * scale;
|
||||
final x2 = cx + (ocr.x2 - 0.5) * imageWidth * scale;
|
||||
final y2 = cy + (ocr.y2 - 0.5) * imageHeight * scale;
|
||||
final x3 = cx + (ocr.x3 - 0.5) * imageWidth * scale;
|
||||
final y3 = cy + (ocr.y3 - 0.5) * imageHeight * scale;
|
||||
final x4 = cx + (ocr.x4 - 0.5) * imageWidth * scale;
|
||||
final y4 = cy + (ocr.y4 - 0.5) * imageHeight * scale;
|
||||
|
||||
// Bounding rectangle for hit testing and Positioned placement
|
||||
final minX = [x1, x2, x3, x4].reduce((a, b) => a < b ? a : b);
|
||||
final maxX = [x1, x2, x3, x4].reduce((a, b) => a > b ? a : b);
|
||||
final minY = [y1, y2, y3, y4].reduce((a, b) => a < b ? a : b);
|
||||
final maxY = [y1, y2, y3, y4].reduce((a, b) => a > b ? a : b);
|
||||
|
||||
return _OcrBoxItem(
|
||||
key: ValueKey(index),
|
||||
ocr: ocr,
|
||||
index: index,
|
||||
isSelected: selectedBoxIndex == index,
|
||||
points: [
|
||||
Offset(x1 - minX, y1 - minY),
|
||||
Offset(x2 - minX, y2 - minY),
|
||||
Offset(x3 - minX, y3 - minY),
|
||||
Offset(x4 - minX, y4 - minY),
|
||||
],
|
||||
left: minX,
|
||||
top: minY,
|
||||
width: maxX - minX,
|
||||
height: maxY - minY,
|
||||
angle: math.atan2(y2 - y1, x2 - x1),
|
||||
labelDx: (minX + maxX) / 2 - minX,
|
||||
labelDy: (minY + maxY) / 2 - minY,
|
||||
onSelectionChanged: onSelectionChanged,
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OcrBoxItem extends StatelessWidget {
|
||||
final Ocr ocr;
|
||||
final int index;
|
||||
final bool isSelected;
|
||||
final List<Offset> points;
|
||||
final double left;
|
||||
final double top;
|
||||
final double width;
|
||||
final double height;
|
||||
final double angle;
|
||||
final double labelDx;
|
||||
final double labelDy;
|
||||
final ValueChanged<int?> onSelectionChanged;
|
||||
|
||||
const _OcrBoxItem({
|
||||
super.key,
|
||||
required this.ocr,
|
||||
required this.index,
|
||||
required this.isSelected,
|
||||
required this.points,
|
||||
required this.left,
|
||||
required this.top,
|
||||
required this.width,
|
||||
required this.height,
|
||||
required this.angle,
|
||||
required this.labelDx,
|
||||
required this.labelDy,
|
||||
required this.onSelectionChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Positioned(
|
||||
left: left,
|
||||
top: top,
|
||||
child: GestureDetector(
|
||||
onTap: () => onSelectionChanged(isSelected ? null : index),
|
||||
behavior: HitTestBehavior.translucent,
|
||||
child: SizedBox(
|
||||
width: width,
|
||||
height: height,
|
||||
child: Stack(
|
||||
children: [
|
||||
CustomPaint(
|
||||
painter: _OcrBoxPainter(
|
||||
points: points,
|
||||
isSelected: isSelected,
|
||||
colorScheme: context.themeData.colorScheme,
|
||||
),
|
||||
size: Size(width, height),
|
||||
),
|
||||
if (isSelected)
|
||||
Positioned(
|
||||
left: labelDx,
|
||||
top: labelDy,
|
||||
child: FractionalTranslation(
|
||||
translation: const Offset(-0.5, -0.5),
|
||||
child: Transform.rotate(
|
||||
angle: angle,
|
||||
alignment: Alignment.center,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(2),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[800]?.withValues(alpha: 0.4),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
||||
),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: math.max(50, width),
|
||||
maxHeight: math.max(20, height),
|
||||
),
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: SelectableText(
|
||||
ocr.text,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: math.max(12, height * 0.6),
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OcrBoxPainter extends CustomPainter {
|
||||
final List<Offset> points;
|
||||
final bool isSelected;
|
||||
final ColorScheme colorScheme;
|
||||
|
||||
const _OcrBoxPainter({required this.points, required this.isSelected, required this.colorScheme});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = isSelected ? colorScheme.primary : colorScheme.secondary
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 2.0;
|
||||
|
||||
final fillPaint = Paint()
|
||||
..color = (isSelected ? colorScheme.primary : colorScheme.secondary).withValues(alpha: 0.1)
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
final path = Path()
|
||||
..moveTo(points[0].dx, points[0].dy)
|
||||
..lineTo(points[1].dx, points[1].dy)
|
||||
..lineTo(points[2].dx, points[2].dy)
|
||||
..lineTo(points[3].dx, points[3].dy)
|
||||
..close();
|
||||
|
||||
canvas.drawPath(path, fillPaint);
|
||||
canvas.drawPath(path, paint);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_OcrBoxPainter oldDelegate) {
|
||||
return oldDelegate.isSelected != isSelected || !listEquals(oldDelegate.points, points);
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,6 @@ import 'package:hooks_riverpod/hooks_riverpod.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/providers/infrastructure/ocr.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart';
|
||||
@@ -16,6 +14,7 @@ import 'package:immich_mobile/providers/infrastructure/current_album.provider.da
|
||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
||||
class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
const ViewerTopAppBar({super.key});
|
||||
@@ -33,7 +32,6 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
|
||||
final isInLockedView = ref.watch(inLockedViewProvider);
|
||||
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
|
||||
final hasOcr = asset is RemoteAsset && ref.watch(ocrAssetProvider(asset.id)).valueOrNull?.isNotEmpty == true;
|
||||
|
||||
final showingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails));
|
||||
|
||||
@@ -45,15 +43,8 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
double opacity = ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)) * (showingControls ? 1 : 0);
|
||||
|
||||
final originalTheme = context.themeData;
|
||||
final showingOcr = ref.watch(assetViewerProvider.select((state) => state.showingOcr));
|
||||
|
||||
final actions = <Widget>[
|
||||
if (hasOcr)
|
||||
IconButton(
|
||||
icon: Icon(showingOcr ? Icons.text_fields : Icons.text_fields_outlined),
|
||||
onPressed: ref.read(assetViewerProvider.notifier).toggleOcr,
|
||||
color: showingOcr ? context.primaryColor : null,
|
||||
),
|
||||
if (asset.isMotionPhoto) const MotionPhotoActionButton(iconOnly: true),
|
||||
if (album != null && album.isActivityEnabled && album.isShared)
|
||||
IconButton(
|
||||
|
||||
@@ -8,7 +8,6 @@ class AssetViewerState {
|
||||
final bool showingDetails;
|
||||
final bool showingControls;
|
||||
final bool isZoomed;
|
||||
final bool showingOcr;
|
||||
final BaseAsset? currentAsset;
|
||||
final int stackIndex;
|
||||
|
||||
@@ -17,7 +16,6 @@ class AssetViewerState {
|
||||
this.showingDetails = false,
|
||||
this.showingControls = true,
|
||||
this.isZoomed = false,
|
||||
this.showingOcr = false,
|
||||
this.currentAsset,
|
||||
this.stackIndex = 0,
|
||||
});
|
||||
@@ -27,7 +25,6 @@ class AssetViewerState {
|
||||
bool? showingDetails,
|
||||
bool? showingControls,
|
||||
bool? isZoomed,
|
||||
bool? showingOcr,
|
||||
BaseAsset? currentAsset,
|
||||
int? stackIndex,
|
||||
}) {
|
||||
@@ -36,7 +33,6 @@ class AssetViewerState {
|
||||
showingDetails: showingDetails ?? this.showingDetails,
|
||||
showingControls: showingControls ?? this.showingControls,
|
||||
isZoomed: isZoomed ?? this.isZoomed,
|
||||
showingOcr: showingOcr ?? this.showingOcr,
|
||||
currentAsset: currentAsset ?? this.currentAsset,
|
||||
stackIndex: stackIndex ?? this.stackIndex,
|
||||
);
|
||||
@@ -44,7 +40,7 @@ class AssetViewerState {
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'AssetViewerState(opacity: $backgroundOpacity, showingDetails: $showingDetails, controls: $showingControls, isZoomed: $isZoomed, showingOcr: $showingOcr)';
|
||||
return 'AssetViewerState(opacity: $backgroundOpacity, showingDetails: $showingDetails, controls: $showingControls, isZoomed: $isZoomed)';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -56,7 +52,6 @@ class AssetViewerState {
|
||||
other.showingDetails == showingDetails &&
|
||||
other.showingControls == showingControls &&
|
||||
other.isZoomed == isZoomed &&
|
||||
other.showingOcr == showingOcr &&
|
||||
other.currentAsset == currentAsset &&
|
||||
other.stackIndex == stackIndex;
|
||||
}
|
||||
@@ -67,7 +62,6 @@ class AssetViewerState {
|
||||
showingDetails.hashCode ^
|
||||
showingControls.hashCode ^
|
||||
isZoomed.hashCode ^
|
||||
showingOcr.hashCode ^
|
||||
currentAsset.hashCode ^
|
||||
stackIndex.hashCode;
|
||||
}
|
||||
@@ -90,7 +84,7 @@ class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
|
||||
|
||||
void setAsset(BaseAsset asset) {
|
||||
if (asset == state.currentAsset) return;
|
||||
state = state.copyWith(currentAsset: asset, stackIndex: 0, showingOcr: false);
|
||||
state = state.copyWith(currentAsset: asset, stackIndex: 0);
|
||||
}
|
||||
|
||||
void setOpacity(double opacity) {
|
||||
@@ -137,10 +131,6 @@ class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
|
||||
}
|
||||
state = state.copyWith(stackIndex: index);
|
||||
}
|
||||
|
||||
void toggleOcr() {
|
||||
state = state.copyWith(showingOcr: !state.showingOcr);
|
||||
}
|
||||
}
|
||||
|
||||
final assetViewerProvider = NotifierProvider<AssetViewerStateNotifier, AssetViewerState>(AssetViewerStateNotifier.new);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/config/app_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/system_config.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
|
||||
|
||||
final metadataProvider = Provider<MetadataRepository>((_) => MetadataRepository.instance);
|
||||
|
||||
final appConfigProvider = Provider.autoDispose<AppConfig>((ref) {
|
||||
final repo = ref.watch(metadataProvider);
|
||||
final subscription = repo.watchAppConfig().listen((event) => ref.state = event);
|
||||
ref.onDispose(subscription.cancel);
|
||||
return repo.appConfig;
|
||||
});
|
||||
|
||||
final systemConfigProvider = Provider.autoDispose<SystemConfig>((ref) {
|
||||
final repo = ref.watch(metadataProvider);
|
||||
final subscription = repo.watchSystemConfig().listen((event) => ref.state = event);
|
||||
ref.onDispose(subscription.cancel);
|
||||
return repo.systemConfig;
|
||||
});
|
||||
@@ -1,14 +0,0 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/ocr.model.dart';
|
||||
import 'package:immich_mobile/domain/services/ocr.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/ocr.repository.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
|
||||
final ocrRepositoryProvider = Provider<OcrRepository>((ref) => OcrRepository(ref.watch(driftProvider)));
|
||||
|
||||
final ocrServiceProvider = Provider<OcrService>((ref) => OcrService(ref.watch(ocrRepositoryProvider)));
|
||||
|
||||
final ocrAssetProvider = FutureProvider.autoDispose.family<List<Ocr>?, String>((ref, assetId) async {
|
||||
final service = ref.watch(ocrServiceProvider);
|
||||
return service.get(assetId);
|
||||
});
|
||||
@@ -1,27 +1,15 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import 'package:immich_mobile/constants/colors.dart';
|
||||
import 'package:immich_mobile/theme/color_scheme.dart';
|
||||
import 'package:immich_mobile/theme/theme_data.dart';
|
||||
import 'package:immich_mobile/theme/dynamic_theme.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/theme/color_scheme.dart';
|
||||
import 'package:immich_mobile/theme/dynamic_theme.dart';
|
||||
import 'package:immich_mobile/theme/theme_data.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
|
||||
final immichThemeModeProvider = StateProvider<ThemeMode>((ref) {
|
||||
final themeMode = ref.watch(appSettingsServiceProvider).getSetting(AppSettingsEnum.themeMode);
|
||||
|
||||
dPrint(() => "Current themeMode $themeMode");
|
||||
|
||||
if (themeMode == ThemeMode.light.name) {
|
||||
return ThemeMode.light;
|
||||
} else if (themeMode == ThemeMode.dark.name) {
|
||||
return ThemeMode.dark;
|
||||
} else {
|
||||
return ThemeMode.system;
|
||||
}
|
||||
});
|
||||
final immichThemeModeProvider = StateProvider<ThemeMode>((ref) => ref.watch(appConfigProvider).theme.mode);
|
||||
|
||||
final immichThemePresetProvider = StateProvider<ImmichColorPreset>((ref) {
|
||||
final appSettingsProvider = ref.watch(appSettingsServiceProvider);
|
||||
|
||||
@@ -5,7 +5,6 @@ import 'package:immich_mobile/entities/store.entity.dart';
|
||||
enum AppSettingsEnum<T> {
|
||||
loadPreview<bool>(StoreKey.loadPreview, "loadPreview", true),
|
||||
loadOriginal<bool>(StoreKey.loadOriginal, "loadOriginal", false),
|
||||
themeMode<String>(StoreKey.themeMode, "themeMode", "system"), // "light","dark","system"
|
||||
primaryColor<String>(StoreKey.primaryColor, "primaryColor", defaultColorPresetName),
|
||||
dynamicTheme<bool>(StoreKey.dynamicTheme, "dynamicTheme", false),
|
||||
colorfulInterface<bool>(StoreKey.colorfulInterface, "colorfulInterface", true),
|
||||
@@ -30,7 +29,6 @@ enum AppSettingsEnum<T> {
|
||||
selectedAlbumSortOrder<int>(StoreKey.selectedAlbumSortOrder, "selectedAlbumSortOrder", 2),
|
||||
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false),
|
||||
manageLocalMediaAndroid<bool>(StoreKey.manageLocalMediaAndroid, null, false),
|
||||
logLevel<int>(StoreKey.logLevel, null, 5), // Level.INFO = 5
|
||||
preferRemoteImage<bool>(StoreKey.preferRemoteImage, null, false),
|
||||
loopVideo<bool>(StoreKey.loopVideo, "loopVideo", true),
|
||||
loadOriginalVideo<bool>(StoreKey.loadOriginalVideo, "loadOriginalVideo", false),
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
@@ -48,9 +49,11 @@ abstract final class Bootstrap {
|
||||
|
||||
await StoreService.init(storeRepository: storeRepo, listenUpdates: listenStoreUpdates);
|
||||
|
||||
final metadataRepo = await MetadataRepository.ensureInitialized(drift);
|
||||
|
||||
await LogService.init(
|
||||
logRepository: LogRepository(logDb),
|
||||
storeRepository: storeRepo,
|
||||
metadataRepository: metadataRepo,
|
||||
shouldBuffer: shouldBufferLogs,
|
||||
);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
|
||||
ValueNotifier<T> useAppSettingsState<T>(AppSettingsEnum<T> key) {
|
||||
final notifier = useState<T>(Store.get(key.storeKey, key.defaultValue));
|
||||
|
||||
@@ -1,25 +1,76 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/domain/models/log.model.dart';
|
||||
import 'package:immich_mobile/domain/models/metadata_key.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/services/log.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
|
||||
const int targetVersion = 25;
|
||||
const int targetVersion = 26;
|
||||
|
||||
Future<void> migrateDatabaseIfNeeded() async {
|
||||
Future<void> migrateDatabaseIfNeeded(Drift drift) async {
|
||||
final int version = Store.get(StoreKey.version, targetVersion);
|
||||
|
||||
if (version < 25) {
|
||||
final accessToken = Store.tryGet(StoreKey.accessToken);
|
||||
if (accessToken != null && accessToken.isNotEmpty) {
|
||||
final serverUrls = ApiService.getServerUrls();
|
||||
if (serverUrls.isNotEmpty) {
|
||||
await NetworkRepository.setHeaders(ApiService.getRequestHeaders(), serverUrls, token: accessToken);
|
||||
}
|
||||
}
|
||||
await _migrateTo25();
|
||||
}
|
||||
|
||||
if (version < 26) {
|
||||
await _migrateTo26(drift);
|
||||
}
|
||||
|
||||
await Store.put(StoreKey.version, targetVersion);
|
||||
return;
|
||||
}
|
||||
|
||||
Future<void> _migrateTo25() async {
|
||||
final accessToken = Store.tryGet(StoreKey.accessToken);
|
||||
if (accessToken == null || accessToken.isEmpty) return;
|
||||
|
||||
final serverUrls = ApiService.getServerUrls();
|
||||
if (serverUrls.isEmpty) return;
|
||||
|
||||
await NetworkRepository.setHeaders(ApiService.getRequestHeaders(), serverUrls, token: accessToken);
|
||||
}
|
||||
|
||||
Future<void> _migrateTo26(Drift drift) async {
|
||||
final repo = MetadataRepository.instance;
|
||||
final migrated = <int>[];
|
||||
|
||||
final themeMode = await _readLegacyStoreString(drift, StoreKey.legacyThemeMode.id);
|
||||
if (themeMode != null) {
|
||||
final mode = ThemeMode.values.firstWhere((m) => m.name == themeMode, orElse: () => ThemeMode.system);
|
||||
await repo.write(MetadataKey.themeMode, mode);
|
||||
migrated.add(StoreKey.legacyThemeMode.id);
|
||||
}
|
||||
|
||||
final logLevelIndex = await _readLegacyStoreInt(drift, StoreKey.legacyLogLevel.id);
|
||||
if (logLevelIndex != null) {
|
||||
final logLevel = LogLevel.values.elementAtOrNull(logLevelIndex) ?? LogLevel.info;
|
||||
await LogService.I.setLogLevel(logLevel);
|
||||
migrated.add(StoreKey.legacyLogLevel.id);
|
||||
}
|
||||
|
||||
await _deleteLegacyStoreRows(drift, migrated);
|
||||
}
|
||||
|
||||
Future<String?> _readLegacyStoreString(Drift drift, int id) async {
|
||||
final row = await (drift.storeEntity.select()..where((t) => t.id.equals(id))).getSingleOrNull();
|
||||
return row?.stringValue;
|
||||
}
|
||||
|
||||
Future<int?> _readLegacyStoreInt(Drift drift, int id) async {
|
||||
final row = await (drift.storeEntity.select()..where((t) => t.id.equals(id))).getSingleOrNull();
|
||||
return row?.intValue;
|
||||
}
|
||||
|
||||
Future<void> _deleteLegacyStoreRows(Drift drift, List<int> ids) async {
|
||||
if (ids.isEmpty) return;
|
||||
await (drift.storeEntity.delete()..where((t) => t.id.isIn(ids))).go();
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/services/log.service.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
||||
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
||||
@@ -30,7 +31,7 @@ class AdvancedSettings extends HookConsumerWidget {
|
||||
final manageLocalMediaAndroid = useAppSettingsState(AppSettingsEnum.manageLocalMediaAndroid);
|
||||
final isManageMediaSupported = useState(false);
|
||||
final manageMediaAndroidPermission = useState(false);
|
||||
final levelId = useAppSettingsState(AppSettingsEnum.logLevel);
|
||||
final levelId = useState<int>(ref.read(systemConfigProvider).logLevel.index);
|
||||
final preferRemote = useAppSettingsState(AppSettingsEnum.preferRemoteImage);
|
||||
final readonlyModeEnabled = useAppSettingsState(AppSettingsEnum.readonlyModeEnabled);
|
||||
|
||||
|
||||
@@ -1,37 +1,29 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/metadata_key.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
|
||||
import 'package:immich_mobile/providers/theme.provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
|
||||
import 'package:immich_mobile/widgets/settings/preference_settings/primary_color_setting.dart';
|
||||
import 'package:immich_mobile/widgets/settings/setting_group_title.dart';
|
||||
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
|
||||
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
|
||||
|
||||
class ThemeSetting extends HookConsumerWidget {
|
||||
const ThemeSetting({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final currentThemeString = useAppSettingsState(AppSettingsEnum.themeMode);
|
||||
final currentTheme = useValueNotifier(ref.read(immichThemeModeProvider));
|
||||
final currentTheme = useState(ref.read(immichThemeModeProvider));
|
||||
final isDarkTheme = useValueNotifier(currentTheme.value == ThemeMode.dark);
|
||||
final isSystemTheme = useValueNotifier(currentTheme.value == ThemeMode.system);
|
||||
|
||||
final applyThemeToBackgroundSetting = useAppSettingsState(AppSettingsEnum.colorfulInterface);
|
||||
final applyThemeToBackgroundProvider = useValueNotifier(ref.read(colorfulInterfaceSettingProvider));
|
||||
|
||||
useValueChanged(
|
||||
currentThemeString.value,
|
||||
(_, __) => currentTheme.value = switch (currentThemeString.value) {
|
||||
"light" => ThemeMode.light,
|
||||
"dark" => ThemeMode.dark,
|
||||
_ => ThemeMode.system,
|
||||
},
|
||||
);
|
||||
|
||||
useValueChanged(
|
||||
applyThemeToBackgroundSetting.value,
|
||||
(_, __) => applyThemeToBackgroundProvider.value = applyThemeToBackgroundSetting.value,
|
||||
@@ -40,16 +32,17 @@ class ThemeSetting extends HookConsumerWidget {
|
||||
void onThemeChange(bool isDark) {
|
||||
if (isDark) {
|
||||
ref.watch(immichThemeModeProvider.notifier).state = ThemeMode.dark;
|
||||
currentThemeString.value = "dark";
|
||||
currentTheme.value = ThemeMode.dark;
|
||||
} else {
|
||||
ref.watch(immichThemeModeProvider.notifier).state = ThemeMode.light;
|
||||
currentThemeString.value = "light";
|
||||
currentTheme.value = ThemeMode.light;
|
||||
}
|
||||
ref.read(metadataProvider).write(MetadataKey.themeMode, currentTheme.value);
|
||||
}
|
||||
|
||||
void onSystemThemeChange(bool isSystem) {
|
||||
if (isSystem) {
|
||||
currentThemeString.value = "system";
|
||||
currentTheme.value = ThemeMode.system;
|
||||
isSystemTheme.value = true;
|
||||
ref.watch(immichThemeModeProvider.notifier).state = ThemeMode.system;
|
||||
} else {
|
||||
@@ -57,13 +50,14 @@ class ThemeSetting extends HookConsumerWidget {
|
||||
isSystemTheme.value = false;
|
||||
isDarkTheme.value = currentSystemBrightness == Brightness.dark;
|
||||
if (currentSystemBrightness == Brightness.light) {
|
||||
currentThemeString.value = "light";
|
||||
currentTheme.value = ThemeMode.light;
|
||||
ref.watch(immichThemeModeProvider.notifier).state = ThemeMode.light;
|
||||
} else if (currentSystemBrightness == Brightness.dark) {
|
||||
currentThemeString.value = "dark";
|
||||
currentTheme.value = ThemeMode.dark;
|
||||
ref.watch(immichThemeModeProvider.notifier).state = ThemeMode.dark;
|
||||
}
|
||||
}
|
||||
ref.read(metadataProvider).write(MetadataKey.themeMode, currentTheme.value);
|
||||
}
|
||||
|
||||
void onSurfaceColorSettingChange(bool useColorfulInterface) {
|
||||
|
||||
Generated
+7
-3
@@ -103,12 +103,16 @@ 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
|
||||
@@ -146,7 +150,7 @@ Class | Method | HTTP request | Description
|
||||
*DeprecatedApi* | [**runQueueCommandLegacy**](doc//DeprecatedApi.md#runqueuecommandlegacy) | **PUT** /jobs/{name} | Run jobs
|
||||
*DownloadApi* | [**downloadArchive**](doc//DownloadApi.md#downloadarchive) | **POST** /download/archive | Download asset archive
|
||||
*DownloadApi* | [**getDownloadInfo**](doc//DownloadApi.md#getdownloadinfo) | **POST** /download/info | Retrieve download information
|
||||
*DuplicatesApi* | [**deleteDuplicate**](doc//DuplicatesApi.md#deleteduplicate) | **DELETE** /duplicates/{id} | Delete a duplicate
|
||||
*DuplicatesApi* | [**deleteDuplicate**](doc//DuplicatesApi.md#deleteduplicate) | **DELETE** /duplicates/{id} | Dismiss a duplicate group
|
||||
*DuplicatesApi* | [**deleteDuplicates**](doc//DuplicatesApi.md#deleteduplicates) | **DELETE** /duplicates | Delete duplicates
|
||||
*DuplicatesApi* | [**getAssetDuplicates**](doc//DuplicatesApi.md#getassetduplicates) | **GET** /duplicates | Retrieve duplicates
|
||||
*DuplicatesApi* | [**resolveDuplicates**](doc//DuplicatesApi.md#resolveduplicates) | **POST** /duplicates/resolve | Resolve duplicate groups
|
||||
@@ -578,9 +582,8 @@ Class | Method | HTTP request | Description
|
||||
- [SyncAssetFaceV2](doc//SyncAssetFaceV2.md)
|
||||
- [SyncAssetMetadataDeleteV1](doc//SyncAssetMetadataDeleteV1.md)
|
||||
- [SyncAssetMetadataV1](doc//SyncAssetMetadataV1.md)
|
||||
- [SyncAssetOcrDeleteV1](doc//SyncAssetOcrDeleteV1.md)
|
||||
- [SyncAssetOcrV1](doc//SyncAssetOcrV1.md)
|
||||
- [SyncAssetV1](doc//SyncAssetV1.md)
|
||||
- [SyncAssetV2](doc//SyncAssetV2.md)
|
||||
- [SyncAuthUserV1](doc//SyncAuthUserV1.md)
|
||||
- [SyncEntityType](doc//SyncEntityType.md)
|
||||
- [SyncMemoryAssetDeleteV1](doc//SyncMemoryAssetDeleteV1.md)
|
||||
@@ -602,6 +605,7 @@ 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
+2
-2
@@ -326,9 +326,8 @@ part 'model/sync_asset_face_v1.dart';
|
||||
part 'model/sync_asset_face_v2.dart';
|
||||
part 'model/sync_asset_metadata_delete_v1.dart';
|
||||
part 'model/sync_asset_metadata_v1.dart';
|
||||
part 'model/sync_asset_ocr_delete_v1.dart';
|
||||
part 'model/sync_asset_ocr_v1.dart';
|
||||
part 'model/sync_asset_v1.dart';
|
||||
part 'model/sync_asset_v2.dart';
|
||||
part 'model/sync_auth_user_v1.dart';
|
||||
part 'model/sync_entity_type.dart';
|
||||
part 'model/sync_memory_asset_delete_v1.dart';
|
||||
@@ -350,6 +349,7 @@ 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
+310
@@ -416,6 +416,75 @@ 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, }) 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,
|
||||
);
|
||||
}
|
||||
|
||||
/// 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, }) async {
|
||||
final response = await endSessionWithHttpInfo(id, sessionId, key: key, slug: slug, );
|
||||
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.
|
||||
@@ -809,6 +878,247 @@ 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, }) 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,
|
||||
);
|
||||
}
|
||||
|
||||
/// 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, }) async {
|
||||
final response = await getMainPlaylistWithHttpInfo(id, key: key, slug: slug, );
|
||||
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, }) 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,
|
||||
);
|
||||
}
|
||||
|
||||
/// 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, }) async {
|
||||
final response = await getMediaPlaylistWithHttpInfo(id, sessionId, variantIndex, key: key, slug: slug, );
|
||||
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, }) 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,
|
||||
);
|
||||
}
|
||||
|
||||
/// 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, }) async {
|
||||
final response = await getSegmentWithHttpInfo(filename, id, sessionId, variantIndex, key: key, slug: slug, );
|
||||
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.
|
||||
|
||||
+4
-4
@@ -16,9 +16,9 @@ class DuplicatesApi {
|
||||
|
||||
final ApiClient apiClient;
|
||||
|
||||
/// Delete a duplicate
|
||||
/// Dismiss a duplicate group
|
||||
///
|
||||
/// Delete a single duplicate asset specified by its ID.
|
||||
/// Dismiss a duplicate group by its ID, unlinking all assets in the group without deleting them.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
@@ -51,9 +51,9 @@ class DuplicatesApi {
|
||||
);
|
||||
}
|
||||
|
||||
/// Delete a duplicate
|
||||
/// Dismiss a duplicate group
|
||||
///
|
||||
/// Delete a single duplicate asset specified by its ID.
|
||||
/// Dismiss a duplicate group by its ID, unlinking all assets in the group without deleting them.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
|
||||
Generated
+4
-4
@@ -698,12 +698,10 @@ class ApiClient {
|
||||
return SyncAssetMetadataDeleteV1.fromJson(value);
|
||||
case 'SyncAssetMetadataV1':
|
||||
return SyncAssetMetadataV1.fromJson(value);
|
||||
case 'SyncAssetOcrDeleteV1':
|
||||
return SyncAssetOcrDeleteV1.fromJson(value);
|
||||
case 'SyncAssetOcrV1':
|
||||
return SyncAssetOcrV1.fromJson(value);
|
||||
case 'SyncAssetV1':
|
||||
return SyncAssetV1.fromJson(value);
|
||||
case 'SyncAssetV2':
|
||||
return SyncAssetV2.fromJson(value);
|
||||
case 'SyncAuthUserV1':
|
||||
return SyncAuthUserV1.fromJson(value);
|
||||
case 'SyncEntityType':
|
||||
@@ -746,6 +744,8 @@ 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
+3
@@ -52,6 +52,7 @@ 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');
|
||||
@@ -110,6 +111,7 @@ class JobName {
|
||||
librarySyncFilesQueueAll,
|
||||
librarySyncFiles,
|
||||
libraryScanQueueAll,
|
||||
hlsSessionCleanup,
|
||||
memoryCleanup,
|
||||
memoryGenerate,
|
||||
notificationsCleanup,
|
||||
@@ -203,6 +205,7 @@ 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;
|
||||
|
||||
+10
-1
@@ -23,6 +23,7 @@ class ServerFeaturesDto {
|
||||
required this.oauthAutoLaunch,
|
||||
required this.ocr,
|
||||
required this.passwordLogin,
|
||||
required this.realtimeTranscoding,
|
||||
required this.reverseGeocoding,
|
||||
required this.search,
|
||||
required this.sidecar,
|
||||
@@ -60,6 +61,9 @@ class ServerFeaturesDto {
|
||||
/// Whether password login is enabled
|
||||
bool passwordLogin;
|
||||
|
||||
/// Whether real-time transcoding is enabled
|
||||
bool realtimeTranscoding;
|
||||
|
||||
/// Whether reverse geocoding is enabled
|
||||
bool reverseGeocoding;
|
||||
|
||||
@@ -87,6 +91,7 @@ class ServerFeaturesDto {
|
||||
other.oauthAutoLaunch == oauthAutoLaunch &&
|
||||
other.ocr == ocr &&
|
||||
other.passwordLogin == passwordLogin &&
|
||||
other.realtimeTranscoding == realtimeTranscoding &&
|
||||
other.reverseGeocoding == reverseGeocoding &&
|
||||
other.search == search &&
|
||||
other.sidecar == sidecar &&
|
||||
@@ -106,6 +111,7 @@ class ServerFeaturesDto {
|
||||
(oauthAutoLaunch.hashCode) +
|
||||
(ocr.hashCode) +
|
||||
(passwordLogin.hashCode) +
|
||||
(realtimeTranscoding.hashCode) +
|
||||
(reverseGeocoding.hashCode) +
|
||||
(search.hashCode) +
|
||||
(sidecar.hashCode) +
|
||||
@@ -113,7 +119,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, 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, realtimeTranscoding=$realtimeTranscoding, reverseGeocoding=$reverseGeocoding, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, trash=$trash]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
@@ -127,6 +133,7 @@ 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;
|
||||
@@ -154,6 +161,7 @@ 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')!,
|
||||
@@ -216,6 +224,7 @@ class ServerFeaturesDto {
|
||||
'oauthAutoLaunch',
|
||||
'ocr',
|
||||
'passwordLogin',
|
||||
'realtimeTranscoding',
|
||||
'reverseGeocoding',
|
||||
'search',
|
||||
'sidecar',
|
||||
|
||||
-120
@@ -1,120 +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 SyncAssetOcrDeleteV1 {
|
||||
/// Returns a new [SyncAssetOcrDeleteV1] instance.
|
||||
SyncAssetOcrDeleteV1({
|
||||
required this.assetId,
|
||||
required this.deletedAt,
|
||||
required this.id,
|
||||
});
|
||||
|
||||
/// Original asset ID of the deleted OCR entry
|
||||
String assetId;
|
||||
|
||||
/// Timestamp when the OCR entry was deleted
|
||||
DateTime deletedAt;
|
||||
|
||||
/// Audit row ID of the deleted OCR entry
|
||||
String id;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is SyncAssetOcrDeleteV1 &&
|
||||
other.assetId == assetId &&
|
||||
other.deletedAt == deletedAt &&
|
||||
other.id == id;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(assetId.hashCode) +
|
||||
(deletedAt.hashCode) +
|
||||
(id.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'SyncAssetOcrDeleteV1[assetId=$assetId, deletedAt=$deletedAt, id=$id]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'assetId'] = this.assetId;
|
||||
json[r'deletedAt'] = _isEpochMarker(r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/')
|
||||
? this.deletedAt.millisecondsSinceEpoch
|
||||
: this.deletedAt.toUtc().toIso8601String();
|
||||
json[r'id'] = this.id;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [SyncAssetOcrDeleteV1] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static SyncAssetOcrDeleteV1? fromJson(dynamic value) {
|
||||
upgradeDto(value, "SyncAssetOcrDeleteV1");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return SyncAssetOcrDeleteV1(
|
||||
assetId: mapValueOfType<String>(json, r'assetId')!,
|
||||
deletedAt: mapDateTime(json, r'deletedAt', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/')!,
|
||||
id: mapValueOfType<String>(json, r'id')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<SyncAssetOcrDeleteV1> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <SyncAssetOcrDeleteV1>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = SyncAssetOcrDeleteV1.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, SyncAssetOcrDeleteV1> mapFromJson(dynamic json) {
|
||||
final map = <String, SyncAssetOcrDeleteV1>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = SyncAssetOcrDeleteV1.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of SyncAssetOcrDeleteV1-objects as value to a dart map
|
||||
static Map<String, List<SyncAssetOcrDeleteV1>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<SyncAssetOcrDeleteV1>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = SyncAssetOcrDeleteV1.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'assetId',
|
||||
'deletedAt',
|
||||
'id',
|
||||
};
|
||||
}
|
||||
|
||||
-217
@@ -1,217 +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 SyncAssetOcrV1 {
|
||||
/// Returns a new [SyncAssetOcrV1] instance.
|
||||
SyncAssetOcrV1({
|
||||
required this.assetId,
|
||||
required this.boxScore,
|
||||
required this.id,
|
||||
required this.isVisible,
|
||||
required this.text,
|
||||
required this.textScore,
|
||||
required this.x1,
|
||||
required this.x2,
|
||||
required this.x3,
|
||||
required this.x4,
|
||||
required this.y1,
|
||||
required this.y2,
|
||||
required this.y3,
|
||||
required this.y4,
|
||||
});
|
||||
|
||||
/// Asset ID
|
||||
String assetId;
|
||||
|
||||
/// Confidence score of the bounding box
|
||||
double boxScore;
|
||||
|
||||
/// OCR entry ID
|
||||
String id;
|
||||
|
||||
/// Whether the OCR entry is visible
|
||||
bool isVisible;
|
||||
|
||||
/// Recognized text content
|
||||
String text;
|
||||
|
||||
/// Confidence score of the recognized text
|
||||
double textScore;
|
||||
|
||||
/// Top-left X coordinate (normalized 0–1)
|
||||
double x1;
|
||||
|
||||
/// Top-right X coordinate (normalized 0–1)
|
||||
double x2;
|
||||
|
||||
/// Bottom-right X coordinate (normalized 0–1)
|
||||
double x3;
|
||||
|
||||
/// Bottom-left X coordinate (normalized 0–1)
|
||||
double x4;
|
||||
|
||||
/// Top-left Y coordinate (normalized 0–1)
|
||||
double y1;
|
||||
|
||||
/// Top-right Y coordinate (normalized 0–1)
|
||||
double y2;
|
||||
|
||||
/// Bottom-right Y coordinate (normalized 0–1)
|
||||
double y3;
|
||||
|
||||
/// Bottom-left Y coordinate (normalized 0–1)
|
||||
double y4;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is SyncAssetOcrV1 &&
|
||||
other.assetId == assetId &&
|
||||
other.boxScore == boxScore &&
|
||||
other.id == id &&
|
||||
other.isVisible == isVisible &&
|
||||
other.text == text &&
|
||||
other.textScore == textScore &&
|
||||
other.x1 == x1 &&
|
||||
other.x2 == x2 &&
|
||||
other.x3 == x3 &&
|
||||
other.x4 == x4 &&
|
||||
other.y1 == y1 &&
|
||||
other.y2 == y2 &&
|
||||
other.y3 == y3 &&
|
||||
other.y4 == y4;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(assetId.hashCode) +
|
||||
(boxScore.hashCode) +
|
||||
(id.hashCode) +
|
||||
(isVisible.hashCode) +
|
||||
(text.hashCode) +
|
||||
(textScore.hashCode) +
|
||||
(x1.hashCode) +
|
||||
(x2.hashCode) +
|
||||
(x3.hashCode) +
|
||||
(x4.hashCode) +
|
||||
(y1.hashCode) +
|
||||
(y2.hashCode) +
|
||||
(y3.hashCode) +
|
||||
(y4.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'SyncAssetOcrV1[assetId=$assetId, boxScore=$boxScore, id=$id, isVisible=$isVisible, text=$text, textScore=$textScore, x1=$x1, x2=$x2, x3=$x3, x4=$x4, y1=$y1, y2=$y2, y3=$y3, y4=$y4]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'assetId'] = this.assetId;
|
||||
json[r'boxScore'] = this.boxScore;
|
||||
json[r'id'] = this.id;
|
||||
json[r'isVisible'] = this.isVisible;
|
||||
json[r'text'] = this.text;
|
||||
json[r'textScore'] = this.textScore;
|
||||
json[r'x1'] = this.x1;
|
||||
json[r'x2'] = this.x2;
|
||||
json[r'x3'] = this.x3;
|
||||
json[r'x4'] = this.x4;
|
||||
json[r'y1'] = this.y1;
|
||||
json[r'y2'] = this.y2;
|
||||
json[r'y3'] = this.y3;
|
||||
json[r'y4'] = this.y4;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [SyncAssetOcrV1] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static SyncAssetOcrV1? fromJson(dynamic value) {
|
||||
upgradeDto(value, "SyncAssetOcrV1");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return SyncAssetOcrV1(
|
||||
assetId: mapValueOfType<String>(json, r'assetId')!,
|
||||
boxScore: (mapValueOfType<num>(json, r'boxScore')!).toDouble(),
|
||||
id: mapValueOfType<String>(json, r'id')!,
|
||||
isVisible: mapValueOfType<bool>(json, r'isVisible')!,
|
||||
text: mapValueOfType<String>(json, r'text')!,
|
||||
textScore: (mapValueOfType<num>(json, r'textScore')!).toDouble(),
|
||||
x1: (mapValueOfType<num>(json, r'x1')!).toDouble(),
|
||||
x2: (mapValueOfType<num>(json, r'x2')!).toDouble(),
|
||||
x3: (mapValueOfType<num>(json, r'x3')!).toDouble(),
|
||||
x4: (mapValueOfType<num>(json, r'x4')!).toDouble(),
|
||||
y1: (mapValueOfType<num>(json, r'y1')!).toDouble(),
|
||||
y2: (mapValueOfType<num>(json, r'y2')!).toDouble(),
|
||||
y3: (mapValueOfType<num>(json, r'y3')!).toDouble(),
|
||||
y4: (mapValueOfType<num>(json, r'y4')!).toDouble(),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<SyncAssetOcrV1> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <SyncAssetOcrV1>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = SyncAssetOcrV1.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, SyncAssetOcrV1> mapFromJson(dynamic json) {
|
||||
final map = <String, SyncAssetOcrV1>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = SyncAssetOcrV1.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of SyncAssetOcrV1-objects as value to a dart map
|
||||
static Map<String, List<SyncAssetOcrV1>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<SyncAssetOcrV1>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = SyncAssetOcrV1.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'assetId',
|
||||
'boxScore',
|
||||
'id',
|
||||
'isVisible',
|
||||
'text',
|
||||
'textScore',
|
||||
'x1',
|
||||
'x2',
|
||||
'x3',
|
||||
'x4',
|
||||
'y1',
|
||||
'y2',
|
||||
'y3',
|
||||
'y4',
|
||||
};
|
||||
}
|
||||
|
||||
+18
-6
@@ -27,18 +27,19 @@ class SyncEntityType {
|
||||
static const userV1 = SyncEntityType._(r'UserV1');
|
||||
static const userDeleteV1 = SyncEntityType._(r'UserDeleteV1');
|
||||
static const assetV1 = SyncEntityType._(r'AssetV1');
|
||||
static const assetV2 = SyncEntityType._(r'AssetV2');
|
||||
static const assetDeleteV1 = SyncEntityType._(r'AssetDeleteV1');
|
||||
static const assetExifV1 = SyncEntityType._(r'AssetExifV1');
|
||||
static const assetEditV1 = SyncEntityType._(r'AssetEditV1');
|
||||
static const assetEditDeleteV1 = SyncEntityType._(r'AssetEditDeleteV1');
|
||||
static const assetMetadataV1 = SyncEntityType._(r'AssetMetadataV1');
|
||||
static const assetMetadataDeleteV1 = SyncEntityType._(r'AssetMetadataDeleteV1');
|
||||
static const assetOcrV1 = SyncEntityType._(r'AssetOcrV1');
|
||||
static const assetOcrDeleteV1 = SyncEntityType._(r'AssetOcrDeleteV1');
|
||||
static const partnerV1 = SyncEntityType._(r'PartnerV1');
|
||||
static const partnerDeleteV1 = SyncEntityType._(r'PartnerDeleteV1');
|
||||
static const partnerAssetV1 = SyncEntityType._(r'PartnerAssetV1');
|
||||
static const partnerAssetV2 = SyncEntityType._(r'PartnerAssetV2');
|
||||
static const partnerAssetBackfillV1 = SyncEntityType._(r'PartnerAssetBackfillV1');
|
||||
static const partnerAssetBackfillV2 = SyncEntityType._(r'PartnerAssetBackfillV2');
|
||||
static const partnerAssetDeleteV1 = SyncEntityType._(r'PartnerAssetDeleteV1');
|
||||
static const partnerAssetExifV1 = SyncEntityType._(r'PartnerAssetExifV1');
|
||||
static const partnerAssetExifBackfillV1 = SyncEntityType._(r'PartnerAssetExifBackfillV1');
|
||||
@@ -52,8 +53,11 @@ class SyncEntityType {
|
||||
static const albumUserBackfillV1 = SyncEntityType._(r'AlbumUserBackfillV1');
|
||||
static const albumUserDeleteV1 = SyncEntityType._(r'AlbumUserDeleteV1');
|
||||
static const albumAssetCreateV1 = SyncEntityType._(r'AlbumAssetCreateV1');
|
||||
static const albumAssetCreateV2 = SyncEntityType._(r'AlbumAssetCreateV2');
|
||||
static const albumAssetUpdateV1 = SyncEntityType._(r'AlbumAssetUpdateV1');
|
||||
static const albumAssetUpdateV2 = SyncEntityType._(r'AlbumAssetUpdateV2');
|
||||
static const albumAssetBackfillV1 = SyncEntityType._(r'AlbumAssetBackfillV1');
|
||||
static const albumAssetBackfillV2 = SyncEntityType._(r'AlbumAssetBackfillV2');
|
||||
static const albumAssetExifCreateV1 = SyncEntityType._(r'AlbumAssetExifCreateV1');
|
||||
static const albumAssetExifUpdateV1 = SyncEntityType._(r'AlbumAssetExifUpdateV1');
|
||||
static const albumAssetExifBackfillV1 = SyncEntityType._(r'AlbumAssetExifBackfillV1');
|
||||
@@ -83,18 +87,19 @@ class SyncEntityType {
|
||||
userV1,
|
||||
userDeleteV1,
|
||||
assetV1,
|
||||
assetV2,
|
||||
assetDeleteV1,
|
||||
assetExifV1,
|
||||
assetEditV1,
|
||||
assetEditDeleteV1,
|
||||
assetMetadataV1,
|
||||
assetMetadataDeleteV1,
|
||||
assetOcrV1,
|
||||
assetOcrDeleteV1,
|
||||
partnerV1,
|
||||
partnerDeleteV1,
|
||||
partnerAssetV1,
|
||||
partnerAssetV2,
|
||||
partnerAssetBackfillV1,
|
||||
partnerAssetBackfillV2,
|
||||
partnerAssetDeleteV1,
|
||||
partnerAssetExifV1,
|
||||
partnerAssetExifBackfillV1,
|
||||
@@ -108,8 +113,11 @@ class SyncEntityType {
|
||||
albumUserBackfillV1,
|
||||
albumUserDeleteV1,
|
||||
albumAssetCreateV1,
|
||||
albumAssetCreateV2,
|
||||
albumAssetUpdateV1,
|
||||
albumAssetUpdateV2,
|
||||
albumAssetBackfillV1,
|
||||
albumAssetBackfillV2,
|
||||
albumAssetExifCreateV1,
|
||||
albumAssetExifUpdateV1,
|
||||
albumAssetExifBackfillV1,
|
||||
@@ -174,18 +182,19 @@ class SyncEntityTypeTypeTransformer {
|
||||
case r'UserV1': return SyncEntityType.userV1;
|
||||
case r'UserDeleteV1': return SyncEntityType.userDeleteV1;
|
||||
case r'AssetV1': return SyncEntityType.assetV1;
|
||||
case r'AssetV2': return SyncEntityType.assetV2;
|
||||
case r'AssetDeleteV1': return SyncEntityType.assetDeleteV1;
|
||||
case r'AssetExifV1': return SyncEntityType.assetExifV1;
|
||||
case r'AssetEditV1': return SyncEntityType.assetEditV1;
|
||||
case r'AssetEditDeleteV1': return SyncEntityType.assetEditDeleteV1;
|
||||
case r'AssetMetadataV1': return SyncEntityType.assetMetadataV1;
|
||||
case r'AssetMetadataDeleteV1': return SyncEntityType.assetMetadataDeleteV1;
|
||||
case r'AssetOcrV1': return SyncEntityType.assetOcrV1;
|
||||
case r'AssetOcrDeleteV1': return SyncEntityType.assetOcrDeleteV1;
|
||||
case r'PartnerV1': return SyncEntityType.partnerV1;
|
||||
case r'PartnerDeleteV1': return SyncEntityType.partnerDeleteV1;
|
||||
case r'PartnerAssetV1': return SyncEntityType.partnerAssetV1;
|
||||
case r'PartnerAssetV2': return SyncEntityType.partnerAssetV2;
|
||||
case r'PartnerAssetBackfillV1': return SyncEntityType.partnerAssetBackfillV1;
|
||||
case r'PartnerAssetBackfillV2': return SyncEntityType.partnerAssetBackfillV2;
|
||||
case r'PartnerAssetDeleteV1': return SyncEntityType.partnerAssetDeleteV1;
|
||||
case r'PartnerAssetExifV1': return SyncEntityType.partnerAssetExifV1;
|
||||
case r'PartnerAssetExifBackfillV1': return SyncEntityType.partnerAssetExifBackfillV1;
|
||||
@@ -199,8 +208,11 @@ class SyncEntityTypeTypeTransformer {
|
||||
case r'AlbumUserBackfillV1': return SyncEntityType.albumUserBackfillV1;
|
||||
case r'AlbumUserDeleteV1': return SyncEntityType.albumUserDeleteV1;
|
||||
case r'AlbumAssetCreateV1': return SyncEntityType.albumAssetCreateV1;
|
||||
case r'AlbumAssetCreateV2': return SyncEntityType.albumAssetCreateV2;
|
||||
case r'AlbumAssetUpdateV1': return SyncEntityType.albumAssetUpdateV1;
|
||||
case r'AlbumAssetUpdateV2': return SyncEntityType.albumAssetUpdateV2;
|
||||
case r'AlbumAssetBackfillV1': return SyncEntityType.albumAssetBackfillV1;
|
||||
case r'AlbumAssetBackfillV2': return SyncEntityType.albumAssetBackfillV2;
|
||||
case r'AlbumAssetExifCreateV1': return SyncEntityType.albumAssetExifCreateV1;
|
||||
case r'AlbumAssetExifUpdateV1': return SyncEntityType.albumAssetExifUpdateV1;
|
||||
case r'AlbumAssetExifBackfillV1': return SyncEntityType.albumAssetExifBackfillV1;
|
||||
|
||||
+9
-3
@@ -28,17 +28,19 @@ class SyncRequestType {
|
||||
static const albumUsersV1 = SyncRequestType._(r'AlbumUsersV1');
|
||||
static const albumToAssetsV1 = SyncRequestType._(r'AlbumToAssetsV1');
|
||||
static const albumAssetsV1 = SyncRequestType._(r'AlbumAssetsV1');
|
||||
static const albumAssetsV2 = SyncRequestType._(r'AlbumAssetsV2');
|
||||
static const albumAssetExifsV1 = SyncRequestType._(r'AlbumAssetExifsV1');
|
||||
static const assetsV1 = SyncRequestType._(r'AssetsV1');
|
||||
static const assetsV2 = SyncRequestType._(r'AssetsV2');
|
||||
static const assetExifsV1 = SyncRequestType._(r'AssetExifsV1');
|
||||
static const assetEditsV1 = SyncRequestType._(r'AssetEditsV1');
|
||||
static const assetMetadataV1 = SyncRequestType._(r'AssetMetadataV1');
|
||||
static const assetOcrV1 = SyncRequestType._(r'AssetOcrV1');
|
||||
static const authUsersV1 = SyncRequestType._(r'AuthUsersV1');
|
||||
static const memoriesV1 = SyncRequestType._(r'MemoriesV1');
|
||||
static const memoryToAssetsV1 = SyncRequestType._(r'MemoryToAssetsV1');
|
||||
static const partnersV1 = SyncRequestType._(r'PartnersV1');
|
||||
static const partnerAssetsV1 = SyncRequestType._(r'PartnerAssetsV1');
|
||||
static const partnerAssetsV2 = SyncRequestType._(r'PartnerAssetsV2');
|
||||
static const partnerAssetExifsV1 = SyncRequestType._(r'PartnerAssetExifsV1');
|
||||
static const partnerStacksV1 = SyncRequestType._(r'PartnerStacksV1');
|
||||
static const stacksV1 = SyncRequestType._(r'StacksV1');
|
||||
@@ -55,17 +57,19 @@ class SyncRequestType {
|
||||
albumUsersV1,
|
||||
albumToAssetsV1,
|
||||
albumAssetsV1,
|
||||
albumAssetsV2,
|
||||
albumAssetExifsV1,
|
||||
assetsV1,
|
||||
assetsV2,
|
||||
assetExifsV1,
|
||||
assetEditsV1,
|
||||
assetMetadataV1,
|
||||
assetOcrV1,
|
||||
authUsersV1,
|
||||
memoriesV1,
|
||||
memoryToAssetsV1,
|
||||
partnersV1,
|
||||
partnerAssetsV1,
|
||||
partnerAssetsV2,
|
||||
partnerAssetExifsV1,
|
||||
partnerStacksV1,
|
||||
stacksV1,
|
||||
@@ -117,17 +121,19 @@ class SyncRequestTypeTypeTransformer {
|
||||
case r'AlbumUsersV1': return SyncRequestType.albumUsersV1;
|
||||
case r'AlbumToAssetsV1': return SyncRequestType.albumToAssetsV1;
|
||||
case r'AlbumAssetsV1': return SyncRequestType.albumAssetsV1;
|
||||
case r'AlbumAssetsV2': return SyncRequestType.albumAssetsV2;
|
||||
case r'AlbumAssetExifsV1': return SyncRequestType.albumAssetExifsV1;
|
||||
case r'AssetsV1': return SyncRequestType.assetsV1;
|
||||
case r'AssetsV2': return SyncRequestType.assetsV2;
|
||||
case r'AssetExifsV1': return SyncRequestType.assetExifsV1;
|
||||
case r'AssetEditsV1': return SyncRequestType.assetEditsV1;
|
||||
case r'AssetMetadataV1': return SyncRequestType.assetMetadataV1;
|
||||
case r'AssetOcrV1': return SyncRequestType.assetOcrV1;
|
||||
case r'AuthUsersV1': return SyncRequestType.authUsersV1;
|
||||
case r'MemoriesV1': return SyncRequestType.memoriesV1;
|
||||
case r'MemoryToAssetsV1': return SyncRequestType.memoryToAssetsV1;
|
||||
case r'PartnersV1': return SyncRequestType.partnersV1;
|
||||
case r'PartnerAssetsV1': return SyncRequestType.partnerAssetsV1;
|
||||
case r'PartnerAssetsV2': return SyncRequestType.partnerAssetsV2;
|
||||
case r'PartnerAssetExifsV1': return SyncRequestType.partnerAssetExifsV1;
|
||||
case r'PartnerStacksV1': return SyncRequestType.partnerStacksV1;
|
||||
case r'StacksV1': return SyncRequestType.stacksV1;
|
||||
|
||||
+9
-1
@@ -25,6 +25,7 @@ class SystemConfigFFmpegDto {
|
||||
required this.maxBitrate,
|
||||
required this.preferredHwDevice,
|
||||
required this.preset,
|
||||
required this.realtime,
|
||||
required this.refs,
|
||||
required this.targetAudioCodec,
|
||||
required this.targetResolution,
|
||||
@@ -79,6 +80,8 @@ class SystemConfigFFmpegDto {
|
||||
/// Preset
|
||||
String preset;
|
||||
|
||||
SystemConfigFFmpegRealtimeDto realtime;
|
||||
|
||||
/// References
|
||||
///
|
||||
/// Minimum value: 0
|
||||
@@ -122,6 +125,7 @@ class SystemConfigFFmpegDto {
|
||||
other.maxBitrate == maxBitrate &&
|
||||
other.preferredHwDevice == preferredHwDevice &&
|
||||
other.preset == preset &&
|
||||
other.realtime == realtime &&
|
||||
other.refs == refs &&
|
||||
other.targetAudioCodec == targetAudioCodec &&
|
||||
other.targetResolution == targetResolution &&
|
||||
@@ -147,6 +151,7 @@ class SystemConfigFFmpegDto {
|
||||
(maxBitrate.hashCode) +
|
||||
(preferredHwDevice.hashCode) +
|
||||
(preset.hashCode) +
|
||||
(realtime.hashCode) +
|
||||
(refs.hashCode) +
|
||||
(targetAudioCodec.hashCode) +
|
||||
(targetResolution.hashCode) +
|
||||
@@ -158,7 +163,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, 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, realtime=$realtime, 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>{};
|
||||
@@ -174,6 +179,7 @@ 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;
|
||||
@@ -207,6 +213,7 @@ 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')!,
|
||||
@@ -275,6 +282,7 @@ class SystemConfigFFmpegDto {
|
||||
'maxBitrate',
|
||||
'preferredHwDevice',
|
||||
'preset',
|
||||
'realtime',
|
||||
'refs',
|
||||
'targetAudioCodec',
|
||||
'targetResolution',
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
//
|
||||
// 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',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/config/system_config.dart';
|
||||
import 'package:immich_mobile/domain/models/log.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/models/metadata_key.dart';
|
||||
import 'package:immich_mobile/domain/services/log.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
@@ -29,21 +29,23 @@ final _kWarnLog = LogMessage(
|
||||
void main() {
|
||||
late LogService sut;
|
||||
late LogRepository mockLogRepo;
|
||||
late DriftStoreRepository mockStoreRepo;
|
||||
late MockMetadataRepository mockMetadataRepository;
|
||||
|
||||
setUp(() async {
|
||||
mockLogRepo = MockLogRepository();
|
||||
mockStoreRepo = MockDriftStoreRepository();
|
||||
mockMetadataRepository = MockMetadataRepository();
|
||||
|
||||
registerFallbackValue(_kInfoLog);
|
||||
registerFallbackValue(LogLevel.info);
|
||||
|
||||
when(() => mockLogRepo.truncate(limit: any(named: 'limit'))).thenAnswer((_) async => {});
|
||||
when(() => mockStoreRepo.tryGet<int>(StoreKey.logLevel)).thenAnswer((_) async => LogLevel.fine.index);
|
||||
when(() => mockMetadataRepository.systemConfig).thenReturn(const SystemConfig(logLevel: LogLevel.fine));
|
||||
when(() => mockMetadataRepository.write<LogLevel>(MetadataKey.logLevel, any())).thenAnswer((_) async {});
|
||||
when(() => mockLogRepo.getAll()).thenAnswer((_) async => []);
|
||||
when(() => mockLogRepo.insert(any())).thenAnswer((_) async => true);
|
||||
when(() => mockLogRepo.insertAll(any())).thenAnswer((_) async => true);
|
||||
|
||||
sut = await LogService.create(logRepository: mockLogRepo, storeRepository: mockStoreRepo);
|
||||
sut = await LogService.create(logRepository: mockLogRepo, metadataRepository: mockMetadataRepository);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
@@ -56,21 +58,22 @@ void main() {
|
||||
expect(limit, kLogTruncateLimit);
|
||||
});
|
||||
|
||||
test('Sets log level based on the store setting', () {
|
||||
verify(() => mockStoreRepo.tryGet<int>(StoreKey.logLevel)).called(1);
|
||||
test('Sets log level based on the metadata repository', () {
|
||||
verify(() => mockMetadataRepository.systemConfig).called(1);
|
||||
expect(Logger.root.level, Level.FINE);
|
||||
});
|
||||
});
|
||||
|
||||
group("Log Service Set Level:", () {
|
||||
setUp(() async {
|
||||
when(() => mockStoreRepo.upsert<int>(StoreKey.logLevel, any())).thenAnswer((_) async => true);
|
||||
await sut.setLogLevel(LogLevel.shout);
|
||||
});
|
||||
|
||||
test('Updates the log level in store', () {
|
||||
final index = verify(() => mockStoreRepo.upsert<int>(StoreKey.logLevel, captureAny())).captured.firstOrNull;
|
||||
expect(index, LogLevel.shout.index);
|
||||
test('Updates the log level via metadata repository', () {
|
||||
final captured = verify(
|
||||
() => mockMetadataRepository.write<LogLevel>(MetadataKey.logLevel, captureAny()),
|
||||
).captured.firstOrNull;
|
||||
expect(captured, LogLevel.shout);
|
||||
});
|
||||
|
||||
test('Sets log level on logger', () {
|
||||
@@ -81,7 +84,11 @@ void main() {
|
||||
group("Log Service Buffer:", () {
|
||||
test('Buffers logs until timer elapses', () {
|
||||
TestUtils.fakeAsync((time) async {
|
||||
sut = await LogService.create(logRepository: mockLogRepo, storeRepository: mockStoreRepo, shouldBuffer: true);
|
||||
sut = await LogService.create(
|
||||
logRepository: mockLogRepo,
|
||||
metadataRepository: mockMetadataRepository,
|
||||
shouldBuffer: true,
|
||||
);
|
||||
|
||||
final logger = Logger(_kInfoLog.logger!);
|
||||
logger.info(_kInfoLog.message);
|
||||
@@ -95,7 +102,11 @@ void main() {
|
||||
|
||||
test('Batch inserts all logs on timer', () {
|
||||
TestUtils.fakeAsync((time) async {
|
||||
sut = await LogService.create(logRepository: mockLogRepo, storeRepository: mockStoreRepo, shouldBuffer: true);
|
||||
sut = await LogService.create(
|
||||
logRepository: mockLogRepo,
|
||||
metadataRepository: mockMetadataRepository,
|
||||
shouldBuffer: true,
|
||||
);
|
||||
|
||||
final logger = Logger(_kInfoLog.logger!);
|
||||
logger.info(_kInfoLog.message);
|
||||
@@ -112,7 +123,11 @@ void main() {
|
||||
|
||||
test('Does not buffer when off', () {
|
||||
TestUtils.fakeAsync((time) async {
|
||||
sut = await LogService.create(logRepository: mockLogRepo, storeRepository: mockStoreRepo, shouldBuffer: false);
|
||||
sut = await LogService.create(
|
||||
logRepository: mockLogRepo,
|
||||
metadataRepository: mockMetadataRepository,
|
||||
shouldBuffer: false,
|
||||
);
|
||||
|
||||
final logger = Logger(_kInfoLog.logger!);
|
||||
logger.info(_kInfoLog.message);
|
||||
@@ -142,7 +157,11 @@ void main() {
|
||||
|
||||
test('Combines result from both DB + Buffer', () {
|
||||
TestUtils.fakeAsync((time) async {
|
||||
sut = await LogService.create(logRepository: mockLogRepo, storeRepository: mockStoreRepo, shouldBuffer: true);
|
||||
sut = await LogService.create(
|
||||
logRepository: mockLogRepo,
|
||||
metadataRepository: mockMetadataRepository,
|
||||
shouldBuffer: true,
|
||||
);
|
||||
|
||||
final logger = Logger(_kWarnLog.logger!);
|
||||
logger.warning(_kWarnLog.message);
|
||||
|
||||
+105
-524
@@ -8792,216 +8792,67 @@ class AssetEditEntityCompanion extends UpdateCompanion<AssetEditEntityData> {
|
||||
}
|
||||
}
|
||||
|
||||
class AssetOcrEntity extends Table
|
||||
with TableInfo<AssetOcrEntity, AssetOcrEntityData> {
|
||||
class Metadata extends Table with TableInfo<Metadata, MetadataData> {
|
||||
@override
|
||||
final GeneratedDatabase attachedDatabase;
|
||||
final String? _alias;
|
||||
AssetOcrEntity(this.attachedDatabase, [this._alias]);
|
||||
late final GeneratedColumn<String> id = GeneratedColumn<String>(
|
||||
'id',
|
||||
Metadata(this.attachedDatabase, [this._alias]);
|
||||
late final GeneratedColumn<String> key = GeneratedColumn<String>(
|
||||
'key',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.string,
|
||||
requiredDuringInsert: true,
|
||||
$customConstraints: 'NOT NULL',
|
||||
);
|
||||
late final GeneratedColumn<String> assetId = GeneratedColumn<String>(
|
||||
'asset_id',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.string,
|
||||
requiredDuringInsert: true,
|
||||
$customConstraints:
|
||||
'NOT NULL REFERENCES remote_asset_entity(id)ON DELETE CASCADE',
|
||||
);
|
||||
late final GeneratedColumn<double> x1 = GeneratedColumn<double>(
|
||||
'x1',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.double,
|
||||
requiredDuringInsert: true,
|
||||
$customConstraints: 'NOT NULL',
|
||||
);
|
||||
late final GeneratedColumn<double> y1 = GeneratedColumn<double>(
|
||||
'y1',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.double,
|
||||
requiredDuringInsert: true,
|
||||
$customConstraints: 'NOT NULL',
|
||||
);
|
||||
late final GeneratedColumn<double> x2 = GeneratedColumn<double>(
|
||||
'x2',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.double,
|
||||
requiredDuringInsert: true,
|
||||
$customConstraints: 'NOT NULL',
|
||||
);
|
||||
late final GeneratedColumn<double> y2 = GeneratedColumn<double>(
|
||||
'y2',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.double,
|
||||
requiredDuringInsert: true,
|
||||
$customConstraints: 'NOT NULL',
|
||||
);
|
||||
late final GeneratedColumn<double> x3 = GeneratedColumn<double>(
|
||||
'x3',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.double,
|
||||
requiredDuringInsert: true,
|
||||
$customConstraints: 'NOT NULL',
|
||||
);
|
||||
late final GeneratedColumn<double> y3 = GeneratedColumn<double>(
|
||||
'y3',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.double,
|
||||
requiredDuringInsert: true,
|
||||
$customConstraints: 'NOT NULL',
|
||||
);
|
||||
late final GeneratedColumn<double> x4 = GeneratedColumn<double>(
|
||||
'x4',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.double,
|
||||
requiredDuringInsert: true,
|
||||
$customConstraints: 'NOT NULL',
|
||||
);
|
||||
late final GeneratedColumn<double> y4 = GeneratedColumn<double>(
|
||||
'y4',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.double,
|
||||
requiredDuringInsert: true,
|
||||
$customConstraints: 'NOT NULL',
|
||||
);
|
||||
late final GeneratedColumn<double> boxScore = GeneratedColumn<double>(
|
||||
'box_score',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.double,
|
||||
requiredDuringInsert: true,
|
||||
$customConstraints: 'NOT NULL',
|
||||
);
|
||||
late final GeneratedColumn<double> textScore = GeneratedColumn<double>(
|
||||
'text_score',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.double,
|
||||
requiredDuringInsert: true,
|
||||
$customConstraints: 'NOT NULL',
|
||||
);
|
||||
late final GeneratedColumn<String> recognizedText = GeneratedColumn<String>(
|
||||
'recognized_text',
|
||||
late final GeneratedColumn<String> value = GeneratedColumn<String>(
|
||||
'value',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.string,
|
||||
requiredDuringInsert: true,
|
||||
$customConstraints: 'NOT NULL',
|
||||
);
|
||||
late final GeneratedColumn<int> isVisible = GeneratedColumn<int>(
|
||||
'is_visible',
|
||||
late final GeneratedColumn<String> updatedAt = GeneratedColumn<String>(
|
||||
'updated_at',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.int,
|
||||
type: DriftSqlType.string,
|
||||
requiredDuringInsert: false,
|
||||
$customConstraints: 'NOT NULL DEFAULT 1 CHECK (is_visible IN (0, 1))',
|
||||
defaultValue: const CustomExpression('1'),
|
||||
$customConstraints: 'NOT NULL DEFAULT CURRENT_TIMESTAMP',
|
||||
defaultValue: const CustomExpression('CURRENT_TIMESTAMP'),
|
||||
);
|
||||
@override
|
||||
List<GeneratedColumn> get $columns => [
|
||||
id,
|
||||
assetId,
|
||||
x1,
|
||||
y1,
|
||||
x2,
|
||||
y2,
|
||||
x3,
|
||||
y3,
|
||||
x4,
|
||||
y4,
|
||||
boxScore,
|
||||
textScore,
|
||||
recognizedText,
|
||||
isVisible,
|
||||
];
|
||||
List<GeneratedColumn> get $columns => [key, value, updatedAt];
|
||||
@override
|
||||
String get aliasedName => _alias ?? actualTableName;
|
||||
@override
|
||||
String get actualTableName => $name;
|
||||
static const String $name = 'asset_ocr_entity';
|
||||
static const String $name = 'metadata';
|
||||
@override
|
||||
Set<GeneratedColumn> get $primaryKey => {id};
|
||||
Set<GeneratedColumn> get $primaryKey => {key};
|
||||
@override
|
||||
AssetOcrEntityData map(Map<String, dynamic> data, {String? tablePrefix}) {
|
||||
MetadataData map(Map<String, dynamic> data, {String? tablePrefix}) {
|
||||
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
|
||||
return AssetOcrEntityData(
|
||||
id: attachedDatabase.typeMapping.read(
|
||||
return MetadataData(
|
||||
key: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.string,
|
||||
data['${effectivePrefix}id'],
|
||||
data['${effectivePrefix}key'],
|
||||
)!,
|
||||
assetId: attachedDatabase.typeMapping.read(
|
||||
value: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.string,
|
||||
data['${effectivePrefix}asset_id'],
|
||||
data['${effectivePrefix}value'],
|
||||
)!,
|
||||
x1: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.double,
|
||||
data['${effectivePrefix}x1'],
|
||||
)!,
|
||||
y1: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.double,
|
||||
data['${effectivePrefix}y1'],
|
||||
)!,
|
||||
x2: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.double,
|
||||
data['${effectivePrefix}x2'],
|
||||
)!,
|
||||
y2: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.double,
|
||||
data['${effectivePrefix}y2'],
|
||||
)!,
|
||||
x3: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.double,
|
||||
data['${effectivePrefix}x3'],
|
||||
)!,
|
||||
y3: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.double,
|
||||
data['${effectivePrefix}y3'],
|
||||
)!,
|
||||
x4: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.double,
|
||||
data['${effectivePrefix}x4'],
|
||||
)!,
|
||||
y4: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.double,
|
||||
data['${effectivePrefix}y4'],
|
||||
)!,
|
||||
boxScore: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.double,
|
||||
data['${effectivePrefix}box_score'],
|
||||
)!,
|
||||
textScore: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.double,
|
||||
data['${effectivePrefix}text_score'],
|
||||
)!,
|
||||
recognizedText: attachedDatabase.typeMapping.read(
|
||||
updatedAt: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.string,
|
||||
data['${effectivePrefix}recognized_text'],
|
||||
)!,
|
||||
isVisible: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.int,
|
||||
data['${effectivePrefix}is_visible'],
|
||||
data['${effectivePrefix}updated_at'],
|
||||
)!,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
AssetOcrEntity createAlias(String alias) {
|
||||
return AssetOcrEntity(attachedDatabase, alias);
|
||||
Metadata createAlias(String alias) {
|
||||
return Metadata(attachedDatabase, alias);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -9009,408 +8860,145 @@ class AssetOcrEntity extends Table
|
||||
@override
|
||||
bool get isStrict => true;
|
||||
@override
|
||||
List<String> get customConstraints => const ['PRIMARY KEY(id)'];
|
||||
List<String> get customConstraints => const ['PRIMARY KEY("key")'];
|
||||
@override
|
||||
bool get dontWriteConstraints => true;
|
||||
}
|
||||
|
||||
class AssetOcrEntityData extends DataClass
|
||||
implements Insertable<AssetOcrEntityData> {
|
||||
final String id;
|
||||
final String assetId;
|
||||
final double x1;
|
||||
final double y1;
|
||||
final double x2;
|
||||
final double y2;
|
||||
final double x3;
|
||||
final double y3;
|
||||
final double x4;
|
||||
final double y4;
|
||||
final double boxScore;
|
||||
final double textScore;
|
||||
final String recognizedText;
|
||||
final int isVisible;
|
||||
const AssetOcrEntityData({
|
||||
required this.id,
|
||||
required this.assetId,
|
||||
required this.x1,
|
||||
required this.y1,
|
||||
required this.x2,
|
||||
required this.y2,
|
||||
required this.x3,
|
||||
required this.y3,
|
||||
required this.x4,
|
||||
required this.y4,
|
||||
required this.boxScore,
|
||||
required this.textScore,
|
||||
required this.recognizedText,
|
||||
required this.isVisible,
|
||||
class MetadataData extends DataClass implements Insertable<MetadataData> {
|
||||
final String key;
|
||||
final String value;
|
||||
final String updatedAt;
|
||||
const MetadataData({
|
||||
required this.key,
|
||||
required this.value,
|
||||
required this.updatedAt,
|
||||
});
|
||||
@override
|
||||
Map<String, Expression> toColumns(bool nullToAbsent) {
|
||||
final map = <String, Expression>{};
|
||||
map['id'] = Variable<String>(id);
|
||||
map['asset_id'] = Variable<String>(assetId);
|
||||
map['x1'] = Variable<double>(x1);
|
||||
map['y1'] = Variable<double>(y1);
|
||||
map['x2'] = Variable<double>(x2);
|
||||
map['y2'] = Variable<double>(y2);
|
||||
map['x3'] = Variable<double>(x3);
|
||||
map['y3'] = Variable<double>(y3);
|
||||
map['x4'] = Variable<double>(x4);
|
||||
map['y4'] = Variable<double>(y4);
|
||||
map['box_score'] = Variable<double>(boxScore);
|
||||
map['text_score'] = Variable<double>(textScore);
|
||||
map['recognized_text'] = Variable<String>(recognizedText);
|
||||
map['is_visible'] = Variable<int>(isVisible);
|
||||
map['key'] = Variable<String>(key);
|
||||
map['value'] = Variable<String>(value);
|
||||
map['updated_at'] = Variable<String>(updatedAt);
|
||||
return map;
|
||||
}
|
||||
|
||||
factory AssetOcrEntityData.fromJson(
|
||||
factory MetadataData.fromJson(
|
||||
Map<String, dynamic> json, {
|
||||
ValueSerializer? serializer,
|
||||
}) {
|
||||
serializer ??= driftRuntimeOptions.defaultSerializer;
|
||||
return AssetOcrEntityData(
|
||||
id: serializer.fromJson<String>(json['id']),
|
||||
assetId: serializer.fromJson<String>(json['assetId']),
|
||||
x1: serializer.fromJson<double>(json['x1']),
|
||||
y1: serializer.fromJson<double>(json['y1']),
|
||||
x2: serializer.fromJson<double>(json['x2']),
|
||||
y2: serializer.fromJson<double>(json['y2']),
|
||||
x3: serializer.fromJson<double>(json['x3']),
|
||||
y3: serializer.fromJson<double>(json['y3']),
|
||||
x4: serializer.fromJson<double>(json['x4']),
|
||||
y4: serializer.fromJson<double>(json['y4']),
|
||||
boxScore: serializer.fromJson<double>(json['boxScore']),
|
||||
textScore: serializer.fromJson<double>(json['textScore']),
|
||||
recognizedText: serializer.fromJson<String>(json['recognizedText']),
|
||||
isVisible: serializer.fromJson<int>(json['isVisible']),
|
||||
return MetadataData(
|
||||
key: serializer.fromJson<String>(json['key']),
|
||||
value: serializer.fromJson<String>(json['value']),
|
||||
updatedAt: serializer.fromJson<String>(json['updatedAt']),
|
||||
);
|
||||
}
|
||||
@override
|
||||
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
|
||||
serializer ??= driftRuntimeOptions.defaultSerializer;
|
||||
return <String, dynamic>{
|
||||
'id': serializer.toJson<String>(id),
|
||||
'assetId': serializer.toJson<String>(assetId),
|
||||
'x1': serializer.toJson<double>(x1),
|
||||
'y1': serializer.toJson<double>(y1),
|
||||
'x2': serializer.toJson<double>(x2),
|
||||
'y2': serializer.toJson<double>(y2),
|
||||
'x3': serializer.toJson<double>(x3),
|
||||
'y3': serializer.toJson<double>(y3),
|
||||
'x4': serializer.toJson<double>(x4),
|
||||
'y4': serializer.toJson<double>(y4),
|
||||
'boxScore': serializer.toJson<double>(boxScore),
|
||||
'textScore': serializer.toJson<double>(textScore),
|
||||
'recognizedText': serializer.toJson<String>(recognizedText),
|
||||
'isVisible': serializer.toJson<int>(isVisible),
|
||||
'key': serializer.toJson<String>(key),
|
||||
'value': serializer.toJson<String>(value),
|
||||
'updatedAt': serializer.toJson<String>(updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
AssetOcrEntityData copyWith({
|
||||
String? id,
|
||||
String? assetId,
|
||||
double? x1,
|
||||
double? y1,
|
||||
double? x2,
|
||||
double? y2,
|
||||
double? x3,
|
||||
double? y3,
|
||||
double? x4,
|
||||
double? y4,
|
||||
double? boxScore,
|
||||
double? textScore,
|
||||
String? recognizedText,
|
||||
int? isVisible,
|
||||
}) => AssetOcrEntityData(
|
||||
id: id ?? this.id,
|
||||
assetId: assetId ?? this.assetId,
|
||||
x1: x1 ?? this.x1,
|
||||
y1: y1 ?? this.y1,
|
||||
x2: x2 ?? this.x2,
|
||||
y2: y2 ?? this.y2,
|
||||
x3: x3 ?? this.x3,
|
||||
y3: y3 ?? this.y3,
|
||||
x4: x4 ?? this.x4,
|
||||
y4: y4 ?? this.y4,
|
||||
boxScore: boxScore ?? this.boxScore,
|
||||
textScore: textScore ?? this.textScore,
|
||||
recognizedText: recognizedText ?? this.recognizedText,
|
||||
isVisible: isVisible ?? this.isVisible,
|
||||
);
|
||||
AssetOcrEntityData copyWithCompanion(AssetOcrEntityCompanion data) {
|
||||
return AssetOcrEntityData(
|
||||
id: data.id.present ? data.id.value : this.id,
|
||||
assetId: data.assetId.present ? data.assetId.value : this.assetId,
|
||||
x1: data.x1.present ? data.x1.value : this.x1,
|
||||
y1: data.y1.present ? data.y1.value : this.y1,
|
||||
x2: data.x2.present ? data.x2.value : this.x2,
|
||||
y2: data.y2.present ? data.y2.value : this.y2,
|
||||
x3: data.x3.present ? data.x3.value : this.x3,
|
||||
y3: data.y3.present ? data.y3.value : this.y3,
|
||||
x4: data.x4.present ? data.x4.value : this.x4,
|
||||
y4: data.y4.present ? data.y4.value : this.y4,
|
||||
boxScore: data.boxScore.present ? data.boxScore.value : this.boxScore,
|
||||
textScore: data.textScore.present ? data.textScore.value : this.textScore,
|
||||
recognizedText: data.recognizedText.present
|
||||
? data.recognizedText.value
|
||||
: this.recognizedText,
|
||||
isVisible: data.isVisible.present ? data.isVisible.value : this.isVisible,
|
||||
MetadataData copyWith({String? key, String? value, String? updatedAt}) =>
|
||||
MetadataData(
|
||||
key: key ?? this.key,
|
||||
value: value ?? this.value,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
);
|
||||
MetadataData copyWithCompanion(MetadataCompanion data) {
|
||||
return MetadataData(
|
||||
key: data.key.present ? data.key.value : this.key,
|
||||
value: data.value.present ? data.value.value : this.value,
|
||||
updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return (StringBuffer('AssetOcrEntityData(')
|
||||
..write('id: $id, ')
|
||||
..write('assetId: $assetId, ')
|
||||
..write('x1: $x1, ')
|
||||
..write('y1: $y1, ')
|
||||
..write('x2: $x2, ')
|
||||
..write('y2: $y2, ')
|
||||
..write('x3: $x3, ')
|
||||
..write('y3: $y3, ')
|
||||
..write('x4: $x4, ')
|
||||
..write('y4: $y4, ')
|
||||
..write('boxScore: $boxScore, ')
|
||||
..write('textScore: $textScore, ')
|
||||
..write('recognizedText: $recognizedText, ')
|
||||
..write('isVisible: $isVisible')
|
||||
return (StringBuffer('MetadataData(')
|
||||
..write('key: $key, ')
|
||||
..write('value: $value, ')
|
||||
..write('updatedAt: $updatedAt')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
id,
|
||||
assetId,
|
||||
x1,
|
||||
y1,
|
||||
x2,
|
||||
y2,
|
||||
x3,
|
||||
y3,
|
||||
x4,
|
||||
y4,
|
||||
boxScore,
|
||||
textScore,
|
||||
recognizedText,
|
||||
isVisible,
|
||||
);
|
||||
int get hashCode => Object.hash(key, value, updatedAt);
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
(other is AssetOcrEntityData &&
|
||||
other.id == this.id &&
|
||||
other.assetId == this.assetId &&
|
||||
other.x1 == this.x1 &&
|
||||
other.y1 == this.y1 &&
|
||||
other.x2 == this.x2 &&
|
||||
other.y2 == this.y2 &&
|
||||
other.x3 == this.x3 &&
|
||||
other.y3 == this.y3 &&
|
||||
other.x4 == this.x4 &&
|
||||
other.y4 == this.y4 &&
|
||||
other.boxScore == this.boxScore &&
|
||||
other.textScore == this.textScore &&
|
||||
other.recognizedText == this.recognizedText &&
|
||||
other.isVisible == this.isVisible);
|
||||
(other is MetadataData &&
|
||||
other.key == this.key &&
|
||||
other.value == this.value &&
|
||||
other.updatedAt == this.updatedAt);
|
||||
}
|
||||
|
||||
class AssetOcrEntityCompanion extends UpdateCompanion<AssetOcrEntityData> {
|
||||
final Value<String> id;
|
||||
final Value<String> assetId;
|
||||
final Value<double> x1;
|
||||
final Value<double> y1;
|
||||
final Value<double> x2;
|
||||
final Value<double> y2;
|
||||
final Value<double> x3;
|
||||
final Value<double> y3;
|
||||
final Value<double> x4;
|
||||
final Value<double> y4;
|
||||
final Value<double> boxScore;
|
||||
final Value<double> textScore;
|
||||
final Value<String> recognizedText;
|
||||
final Value<int> isVisible;
|
||||
const AssetOcrEntityCompanion({
|
||||
this.id = const Value.absent(),
|
||||
this.assetId = const Value.absent(),
|
||||
this.x1 = const Value.absent(),
|
||||
this.y1 = const Value.absent(),
|
||||
this.x2 = const Value.absent(),
|
||||
this.y2 = const Value.absent(),
|
||||
this.x3 = const Value.absent(),
|
||||
this.y3 = const Value.absent(),
|
||||
this.x4 = const Value.absent(),
|
||||
this.y4 = const Value.absent(),
|
||||
this.boxScore = const Value.absent(),
|
||||
this.textScore = const Value.absent(),
|
||||
this.recognizedText = const Value.absent(),
|
||||
this.isVisible = const Value.absent(),
|
||||
class MetadataCompanion extends UpdateCompanion<MetadataData> {
|
||||
final Value<String> key;
|
||||
final Value<String> value;
|
||||
final Value<String> updatedAt;
|
||||
const MetadataCompanion({
|
||||
this.key = const Value.absent(),
|
||||
this.value = const Value.absent(),
|
||||
this.updatedAt = const Value.absent(),
|
||||
});
|
||||
AssetOcrEntityCompanion.insert({
|
||||
required String id,
|
||||
required String assetId,
|
||||
required double x1,
|
||||
required double y1,
|
||||
required double x2,
|
||||
required double y2,
|
||||
required double x3,
|
||||
required double y3,
|
||||
required double x4,
|
||||
required double y4,
|
||||
required double boxScore,
|
||||
required double textScore,
|
||||
required String recognizedText,
|
||||
this.isVisible = const Value.absent(),
|
||||
}) : id = Value(id),
|
||||
assetId = Value(assetId),
|
||||
x1 = Value(x1),
|
||||
y1 = Value(y1),
|
||||
x2 = Value(x2),
|
||||
y2 = Value(y2),
|
||||
x3 = Value(x3),
|
||||
y3 = Value(y3),
|
||||
x4 = Value(x4),
|
||||
y4 = Value(y4),
|
||||
boxScore = Value(boxScore),
|
||||
textScore = Value(textScore),
|
||||
recognizedText = Value(recognizedText);
|
||||
static Insertable<AssetOcrEntityData> custom({
|
||||
Expression<String>? id,
|
||||
Expression<String>? assetId,
|
||||
Expression<double>? x1,
|
||||
Expression<double>? y1,
|
||||
Expression<double>? x2,
|
||||
Expression<double>? y2,
|
||||
Expression<double>? x3,
|
||||
Expression<double>? y3,
|
||||
Expression<double>? x4,
|
||||
Expression<double>? y4,
|
||||
Expression<double>? boxScore,
|
||||
Expression<double>? textScore,
|
||||
Expression<String>? recognizedText,
|
||||
Expression<int>? isVisible,
|
||||
MetadataCompanion.insert({
|
||||
required String key,
|
||||
required String value,
|
||||
this.updatedAt = const Value.absent(),
|
||||
}) : key = Value(key),
|
||||
value = Value(value);
|
||||
static Insertable<MetadataData> custom({
|
||||
Expression<String>? key,
|
||||
Expression<String>? value,
|
||||
Expression<String>? updatedAt,
|
||||
}) {
|
||||
return RawValuesInsertable({
|
||||
if (id != null) 'id': id,
|
||||
if (assetId != null) 'asset_id': assetId,
|
||||
if (x1 != null) 'x1': x1,
|
||||
if (y1 != null) 'y1': y1,
|
||||
if (x2 != null) 'x2': x2,
|
||||
if (y2 != null) 'y2': y2,
|
||||
if (x3 != null) 'x3': x3,
|
||||
if (y3 != null) 'y3': y3,
|
||||
if (x4 != null) 'x4': x4,
|
||||
if (y4 != null) 'y4': y4,
|
||||
if (boxScore != null) 'box_score': boxScore,
|
||||
if (textScore != null) 'text_score': textScore,
|
||||
if (recognizedText != null) 'recognized_text': recognizedText,
|
||||
if (isVisible != null) 'is_visible': isVisible,
|
||||
if (key != null) 'key': key,
|
||||
if (value != null) 'value': value,
|
||||
if (updatedAt != null) 'updated_at': updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
AssetOcrEntityCompanion copyWith({
|
||||
Value<String>? id,
|
||||
Value<String>? assetId,
|
||||
Value<double>? x1,
|
||||
Value<double>? y1,
|
||||
Value<double>? x2,
|
||||
Value<double>? y2,
|
||||
Value<double>? x3,
|
||||
Value<double>? y3,
|
||||
Value<double>? x4,
|
||||
Value<double>? y4,
|
||||
Value<double>? boxScore,
|
||||
Value<double>? textScore,
|
||||
Value<String>? recognizedText,
|
||||
Value<int>? isVisible,
|
||||
MetadataCompanion copyWith({
|
||||
Value<String>? key,
|
||||
Value<String>? value,
|
||||
Value<String>? updatedAt,
|
||||
}) {
|
||||
return AssetOcrEntityCompanion(
|
||||
id: id ?? this.id,
|
||||
assetId: assetId ?? this.assetId,
|
||||
x1: x1 ?? this.x1,
|
||||
y1: y1 ?? this.y1,
|
||||
x2: x2 ?? this.x2,
|
||||
y2: y2 ?? this.y2,
|
||||
x3: x3 ?? this.x3,
|
||||
y3: y3 ?? this.y3,
|
||||
x4: x4 ?? this.x4,
|
||||
y4: y4 ?? this.y4,
|
||||
boxScore: boxScore ?? this.boxScore,
|
||||
textScore: textScore ?? this.textScore,
|
||||
recognizedText: recognizedText ?? this.recognizedText,
|
||||
isVisible: isVisible ?? this.isVisible,
|
||||
return MetadataCompanion(
|
||||
key: key ?? this.key,
|
||||
value: value ?? this.value,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, Expression> toColumns(bool nullToAbsent) {
|
||||
final map = <String, Expression>{};
|
||||
if (id.present) {
|
||||
map['id'] = Variable<String>(id.value);
|
||||
if (key.present) {
|
||||
map['key'] = Variable<String>(key.value);
|
||||
}
|
||||
if (assetId.present) {
|
||||
map['asset_id'] = Variable<String>(assetId.value);
|
||||
if (value.present) {
|
||||
map['value'] = Variable<String>(value.value);
|
||||
}
|
||||
if (x1.present) {
|
||||
map['x1'] = Variable<double>(x1.value);
|
||||
}
|
||||
if (y1.present) {
|
||||
map['y1'] = Variable<double>(y1.value);
|
||||
}
|
||||
if (x2.present) {
|
||||
map['x2'] = Variable<double>(x2.value);
|
||||
}
|
||||
if (y2.present) {
|
||||
map['y2'] = Variable<double>(y2.value);
|
||||
}
|
||||
if (x3.present) {
|
||||
map['x3'] = Variable<double>(x3.value);
|
||||
}
|
||||
if (y3.present) {
|
||||
map['y3'] = Variable<double>(y3.value);
|
||||
}
|
||||
if (x4.present) {
|
||||
map['x4'] = Variable<double>(x4.value);
|
||||
}
|
||||
if (y4.present) {
|
||||
map['y4'] = Variable<double>(y4.value);
|
||||
}
|
||||
if (boxScore.present) {
|
||||
map['box_score'] = Variable<double>(boxScore.value);
|
||||
}
|
||||
if (textScore.present) {
|
||||
map['text_score'] = Variable<double>(textScore.value);
|
||||
}
|
||||
if (recognizedText.present) {
|
||||
map['recognized_text'] = Variable<String>(recognizedText.value);
|
||||
}
|
||||
if (isVisible.present) {
|
||||
map['is_visible'] = Variable<int>(isVisible.value);
|
||||
if (updatedAt.present) {
|
||||
map['updated_at'] = Variable<String>(updatedAt.value);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return (StringBuffer('AssetOcrEntityCompanion(')
|
||||
..write('id: $id, ')
|
||||
..write('assetId: $assetId, ')
|
||||
..write('x1: $x1, ')
|
||||
..write('y1: $y1, ')
|
||||
..write('x2: $x2, ')
|
||||
..write('y2: $y2, ')
|
||||
..write('x3: $x3, ')
|
||||
..write('y3: $y3, ')
|
||||
..write('x4: $x4, ')
|
||||
..write('y4: $y4, ')
|
||||
..write('boxScore: $boxScore, ')
|
||||
..write('textScore: $textScore, ')
|
||||
..write('recognizedText: $recognizedText, ')
|
||||
..write('isVisible: $isVisible')
|
||||
return (StringBuffer('MetadataCompanion(')
|
||||
..write('key: $key, ')
|
||||
..write('value: $value, ')
|
||||
..write('updatedAt: $updatedAt')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
@@ -9488,7 +9076,7 @@ class DatabaseAtV25 extends GeneratedDatabase {
|
||||
late final TrashedLocalAssetEntity trashedLocalAssetEntity =
|
||||
TrashedLocalAssetEntity(this);
|
||||
late final AssetEditEntity assetEditEntity = AssetEditEntity(this);
|
||||
late final AssetOcrEntity assetOcrEntity = AssetOcrEntity(this);
|
||||
late final Metadata metadata = Metadata(this);
|
||||
late final Index idxPartnerSharedWithId = Index(
|
||||
'idx_partner_shared_with_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)',
|
||||
@@ -9566,7 +9154,7 @@ class DatabaseAtV25 extends GeneratedDatabase {
|
||||
storeEntity,
|
||||
trashedLocalAssetEntity,
|
||||
assetEditEntity,
|
||||
assetOcrEntity,
|
||||
metadata,
|
||||
idxPartnerSharedWithId,
|
||||
idxLatLng,
|
||||
idxRemoteAlbumAssetAlbumAsset,
|
||||
@@ -9748,13 +9336,6 @@ class DatabaseAtV25 extends GeneratedDatabase {
|
||||
),
|
||||
result: [TableUpdate('asset_edit_entity', kind: UpdateKind.delete)],
|
||||
),
|
||||
WritePropagation(
|
||||
on: TableUpdateQuery.onTableName(
|
||||
'remote_asset_entity',
|
||||
limitUpdateKind: UpdateKind.delete,
|
||||
),
|
||||
result: [TableUpdate('asset_ocr_entity', kind: UpdateKind.delete)],
|
||||
),
|
||||
]);
|
||||
@override
|
||||
int get schemaVersion => 25;
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||
@@ -17,6 +18,8 @@ import 'package:mocktail/mocktail.dart';
|
||||
|
||||
class MockDriftStoreRepository extends Mock implements DriftStoreRepository {}
|
||||
|
||||
class MockMetadataRepository extends Mock implements MetadataRepository {}
|
||||
|
||||
class MockLogRepository extends Mock implements LogRepository {}
|
||||
|
||||
class MockSyncStreamRepository extends Mock implements SyncStreamRepository {}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/models/log.model.dart';
|
||||
import 'package:immich_mobile/domain/models/metadata_key.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/metadata.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
|
||||
|
||||
import '../repository_context.dart';
|
||||
|
||||
void main() {
|
||||
late MediumRepositoryContext ctx;
|
||||
late MetadataRepository sut;
|
||||
|
||||
setUpAll(() async {
|
||||
ctx = MediumRepositoryContext();
|
||||
sut = await MetadataRepository.ensureInitialized(ctx.db);
|
||||
});
|
||||
|
||||
tearDownAll(() async {
|
||||
await ctx.dispose();
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
await ctx.db.delete(ctx.db.metadataEntity).go();
|
||||
await MetadataRepository.refresh();
|
||||
});
|
||||
|
||||
group('defaults', () {
|
||||
test('appConfig returns key defaults when DB is empty', () {
|
||||
expect(sut.appConfig.theme.mode, ThemeMode.system);
|
||||
});
|
||||
|
||||
test('systemConfig returns key defaults when DB is empty', () {
|
||||
expect(sut.systemConfig.logLevel, LogLevel.info);
|
||||
});
|
||||
});
|
||||
|
||||
group('write', () {
|
||||
test('persists a value and reflects it in the composed view', () async {
|
||||
await sut.write(.themeMode, ThemeMode.dark);
|
||||
expect(sut.appConfig.theme.mode, ThemeMode.dark);
|
||||
});
|
||||
|
||||
test('persists across domains independently', () async {
|
||||
await sut.write(.themeMode, ThemeMode.light);
|
||||
await sut.write(.logLevel, LogLevel.severe);
|
||||
expect(sut.appConfig.theme.mode, ThemeMode.light);
|
||||
expect(sut.systemConfig.logLevel, LogLevel.severe);
|
||||
});
|
||||
});
|
||||
|
||||
group('delete', () {
|
||||
test('removes the row and reverts to default', () async {
|
||||
await sut.write(.themeMode, ThemeMode.dark);
|
||||
expect(sut.appConfig.theme.mode, ThemeMode.dark);
|
||||
|
||||
await sut.delete(.themeMode);
|
||||
expect(sut.appConfig.theme.mode, ThemeMode.system);
|
||||
|
||||
final rows = await ctx.db.select(ctx.db.metadataEntity).get();
|
||||
expect(rows, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('refresh', () {
|
||||
test('picks up rows that were inserted directly into the DB', () async {
|
||||
await ctx.db
|
||||
.into(ctx.db.metadataEntity)
|
||||
.insert(
|
||||
MetadataEntityCompanion.insert(
|
||||
key: MetadataKey.themeMode.key,
|
||||
value: ThemeMode.dark.name,
|
||||
updatedAt: Value(DateTime.now()),
|
||||
),
|
||||
);
|
||||
|
||||
// Cache hasn't seen this row yet — view still returns the default.
|
||||
expect(sut.appConfig.theme.mode, ThemeMode.system);
|
||||
|
||||
await MetadataRepository.refresh();
|
||||
|
||||
expect(sut.appConfig.theme.mode, ThemeMode.dark);
|
||||
});
|
||||
|
||||
test('drops cached values for rows that were deleted out from under the repo', () async {
|
||||
await sut.write(.themeMode, ThemeMode.dark);
|
||||
// Wipe the row directly. Cache still holds the old value.
|
||||
await ctx.db.delete(ctx.db.metadataEntity).go();
|
||||
expect(sut.appConfig.theme.mode, ThemeMode.dark);
|
||||
|
||||
await MetadataRepository.refresh();
|
||||
|
||||
expect(sut.appConfig.theme.mode, ThemeMode.system);
|
||||
});
|
||||
|
||||
test('skips rows whose key is unknown to MetadataKey', () async {
|
||||
await ctx.db
|
||||
.into(ctx.db.metadataEntity)
|
||||
.insert(
|
||||
MetadataEntityCompanion.insert(
|
||||
key: 'app-config.unknown.future-key',
|
||||
value: 'whatever',
|
||||
updatedAt: Value(DateTime.now()),
|
||||
),
|
||||
);
|
||||
|
||||
await MetadataRepository.refresh();
|
||||
expect(sut.appConfig.theme.mode, ThemeMode.system);
|
||||
});
|
||||
});
|
||||
|
||||
group('watch', () {
|
||||
test('watchAppConfig emits the new value after a write', () async {
|
||||
final expectation = expectLater(sut.watchAppConfig().map((c) => c.theme.mode), emitsThrough(ThemeMode.dark));
|
||||
await sut.write(MetadataKey.themeMode, ThemeMode.dark);
|
||||
await expectation;
|
||||
});
|
||||
|
||||
test('watchAppConfig does not emit when only system-config rows change', () async {
|
||||
final emissions = <ThemeMode>[];
|
||||
// skip(1) drops the on-subscribe replay so we only capture emissions caused by the write below.
|
||||
final sub = sut.watchAppConfig().skip(1).listen((c) => emissions.add(c.theme.mode));
|
||||
|
||||
await sut.write(MetadataKey.logLevel, LogLevel.severe);
|
||||
await pumpEventQueue();
|
||||
await sub.cancel();
|
||||
|
||||
expect(emissions, isEmpty);
|
||||
});
|
||||
|
||||
test('watchSystemConfig emits the new value after a write', () async {
|
||||
final expectation = expectLater(sut.watchSystemConfig().map((c) => c.logLevel), emitsThrough(LogLevel.warning));
|
||||
await sut.write(MetadataKey.logLevel, LogLevel.warning);
|
||||
await expectation;
|
||||
});
|
||||
});
|
||||
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import 'package:immich_mobile/domain/services/store.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/services/auth.service.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
@@ -109,36 +108,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('logout', () {
|
||||
test('Should logout user', () async {
|
||||
when(() => authApiRepository.logout()).thenAnswer((_) async => {});
|
||||
when(() => backgroundSyncManager.cancel()).thenAnswer((_) async => {});
|
||||
when(() => authRepository.clearLocalData()).thenAnswer((_) => Future.value(null));
|
||||
when(
|
||||
() => appSettingsService.setSetting(AppSettingsEnum.enableBackup, false),
|
||||
).thenAnswer((_) => Future.value(null));
|
||||
await sut.logout();
|
||||
|
||||
verify(() => authApiRepository.logout()).called(1);
|
||||
verify(() => backgroundSyncManager.cancel()).called(1);
|
||||
verify(() => authRepository.clearLocalData()).called(1);
|
||||
});
|
||||
|
||||
test('Should clear local data even on server error', () async {
|
||||
when(() => authApiRepository.logout()).thenThrow(Exception('Server error'));
|
||||
when(() => backgroundSyncManager.cancel()).thenAnswer((_) async => {});
|
||||
when(() => authRepository.clearLocalData()).thenAnswer((_) => Future.value(null));
|
||||
when(
|
||||
() => appSettingsService.setSetting(AppSettingsEnum.enableBackup, false),
|
||||
).thenAnswer((_) => Future.value(null));
|
||||
await sut.logout();
|
||||
|
||||
verify(() => authApiRepository.logout()).called(1);
|
||||
verify(() => backgroundSyncManager.cancel()).called(1);
|
||||
verify(() => authRepository.clearLocalData()).called(1);
|
||||
});
|
||||
});
|
||||
|
||||
group('setOpenApiServiceEndpoint', () {
|
||||
setUp(() {
|
||||
when(() => networkService.getWifiName()).thenAnswer((_) async => 'TestWifi');
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/models/metadata_key.dart';
|
||||
|
||||
void main() {
|
||||
group('MetadataKey', () {
|
||||
test('every key round-trips its default value losslessly', () {
|
||||
for (final key in MetadataKey.values) {
|
||||
final encoded = key.encode(key.defaultValue);
|
||||
final decoded = key.decode(encoded);
|
||||
expect(decoded, key.defaultValue, reason: 'round-trip failed for ${key.name}');
|
||||
}
|
||||
});
|
||||
|
||||
test('decode falls back to the default value when the raw input is unparseable', () {
|
||||
for (final key in MetadataKey.values) {
|
||||
expect(
|
||||
key.decode('not a valid encoding for any key'),
|
||||
key.defaultValue,
|
||||
reason: 'fallback failed for ${key.name}',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
+369
-117
@@ -4300,6 +4300,351 @@
|
||||
"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.",
|
||||
@@ -5172,7 +5517,7 @@
|
||||
},
|
||||
"/duplicates/{id}": {
|
||||
"delete": {
|
||||
"description": "Delete a single duplicate asset specified by its ID.",
|
||||
"description": "Dismiss a duplicate group by its ID, unlinking all assets in the group without deleting them.",
|
||||
"operationId": "deleteDuplicate",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -5202,7 +5547,7 @@
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "Delete a duplicate",
|
||||
"summary": "Dismiss a duplicate group",
|
||||
"tags": [
|
||||
"Duplicates"
|
||||
],
|
||||
@@ -17897,6 +18242,7 @@
|
||||
"LibrarySyncFilesQueueAll",
|
||||
"LibrarySyncFiles",
|
||||
"LibraryScanQueueAll",
|
||||
"HlsSessionCleanup",
|
||||
"MemoryCleanup",
|
||||
"MemoryGenerate",
|
||||
"NotificationsCleanup",
|
||||
@@ -21221,6 +21567,10 @@
|
||||
"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"
|
||||
@@ -21253,6 +21603,7 @@
|
||||
"oauthAutoLaunch",
|
||||
"ocr",
|
||||
"passwordLogin",
|
||||
"realtimeTranscoding",
|
||||
"reverseGeocoding",
|
||||
"search",
|
||||
"sidecar",
|
||||
@@ -23052,118 +23403,6 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SyncAssetOcrDeleteV1": {
|
||||
"properties": {
|
||||
"assetId": {
|
||||
"description": "Original asset ID of the deleted OCR entry",
|
||||
"type": "string"
|
||||
},
|
||||
"deletedAt": {
|
||||
"description": "Timestamp when the OCR entry was deleted",
|
||||
"example": "2024-01-01T00:00:00.000Z",
|
||||
"format": "date-time",
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"description": "Audit row ID of the deleted OCR entry",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"assetId",
|
||||
"deletedAt",
|
||||
"id"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SyncAssetOcrV1": {
|
||||
"properties": {
|
||||
"assetId": {
|
||||
"description": "Asset ID",
|
||||
"type": "string"
|
||||
},
|
||||
"boxScore": {
|
||||
"description": "Confidence score of the bounding box",
|
||||
"format": "double",
|
||||
"type": "number"
|
||||
},
|
||||
"id": {
|
||||
"description": "OCR entry ID",
|
||||
"type": "string"
|
||||
},
|
||||
"isVisible": {
|
||||
"description": "Whether the OCR entry is visible",
|
||||
"type": "boolean"
|
||||
},
|
||||
"text": {
|
||||
"description": "Recognized text content",
|
||||
"type": "string"
|
||||
},
|
||||
"textScore": {
|
||||
"description": "Confidence score of the recognized text",
|
||||
"format": "double",
|
||||
"type": "number"
|
||||
},
|
||||
"x1": {
|
||||
"description": "Top-left X coordinate (normalized 0–1)",
|
||||
"format": "double",
|
||||
"type": "number"
|
||||
},
|
||||
"x2": {
|
||||
"description": "Top-right X coordinate (normalized 0–1)",
|
||||
"format": "double",
|
||||
"type": "number"
|
||||
},
|
||||
"x3": {
|
||||
"description": "Bottom-right X coordinate (normalized 0–1)",
|
||||
"format": "double",
|
||||
"type": "number"
|
||||
},
|
||||
"x4": {
|
||||
"description": "Bottom-left X coordinate (normalized 0–1)",
|
||||
"format": "double",
|
||||
"type": "number"
|
||||
},
|
||||
"y1": {
|
||||
"description": "Top-left Y coordinate (normalized 0–1)",
|
||||
"format": "double",
|
||||
"type": "number"
|
||||
},
|
||||
"y2": {
|
||||
"description": "Top-right Y coordinate (normalized 0–1)",
|
||||
"format": "double",
|
||||
"type": "number"
|
||||
},
|
||||
"y3": {
|
||||
"description": "Bottom-right Y coordinate (normalized 0–1)",
|
||||
"format": "double",
|
||||
"type": "number"
|
||||
},
|
||||
"y4": {
|
||||
"description": "Bottom-left Y coordinate (normalized 0–1)",
|
||||
"format": "double",
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"assetId",
|
||||
"boxScore",
|
||||
"id",
|
||||
"isVisible",
|
||||
"text",
|
||||
"textScore",
|
||||
"x1",
|
||||
"x2",
|
||||
"x3",
|
||||
"x4",
|
||||
"y1",
|
||||
"y2",
|
||||
"y3",
|
||||
"y4"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SyncAssetV1": {
|
||||
"properties": {
|
||||
"checksum": {
|
||||
@@ -23527,8 +23766,6 @@
|
||||
"AssetEditDeleteV1",
|
||||
"AssetMetadataV1",
|
||||
"AssetMetadataDeleteV1",
|
||||
"AssetOcrV1",
|
||||
"AssetOcrDeleteV1",
|
||||
"PartnerV1",
|
||||
"PartnerDeleteV1",
|
||||
"PartnerAssetV1",
|
||||
@@ -23851,7 +24088,6 @@
|
||||
"AssetExifsV1",
|
||||
"AssetEditsV1",
|
||||
"AssetMetadataV1",
|
||||
"AssetOcrV1",
|
||||
"AuthUsersV1",
|
||||
"MemoriesV1",
|
||||
"MemoryToAssetsV1",
|
||||
@@ -24210,6 +24446,9 @@
|
||||
"description": "Preset",
|
||||
"type": "string"
|
||||
},
|
||||
"realtime": {
|
||||
"$ref": "#/components/schemas/SystemConfigFFmpegRealtimeDto"
|
||||
},
|
||||
"refs": {
|
||||
"description": "References",
|
||||
"maximum": 6,
|
||||
@@ -24260,6 +24499,7 @@
|
||||
"maxBitrate",
|
||||
"preferredHwDevice",
|
||||
"preset",
|
||||
"realtime",
|
||||
"refs",
|
||||
"targetAudioCodec",
|
||||
"targetResolution",
|
||||
@@ -24272,6 +24512,18 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SystemConfigFFmpegRealtimeDto": {
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"description": "Enable real-time HLS transcoding (alpha)",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"enabled"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SystemConfigFacesDto": {
|
||||
"properties": {
|
||||
"import": {
|
||||
|
||||
@@ -2049,6 +2049,8 @@ 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 */
|
||||
@@ -2307,6 +2309,10 @@ 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 */
|
||||
@@ -2330,6 +2336,7 @@ export type SystemConfigFFmpegDto = {
|
||||
preferredHwDevice: string;
|
||||
/** Preset */
|
||||
preset: string;
|
||||
realtime: SystemConfigFFmpegRealtimeDto;
|
||||
/** References */
|
||||
refs: number;
|
||||
targetAudioCodec: AudioCodec;
|
||||
@@ -3037,44 +3044,6 @@ export type SyncAssetMetadataV1 = {
|
||||
[key: string]: any;
|
||||
};
|
||||
};
|
||||
export type SyncAssetOcrDeleteV1 = {
|
||||
/** Original asset ID of the deleted OCR entry */
|
||||
assetId: string;
|
||||
/** Timestamp when the OCR entry was deleted */
|
||||
deletedAt: string;
|
||||
/** Audit row ID of the deleted OCR entry */
|
||||
id: string;
|
||||
};
|
||||
export type SyncAssetOcrV1 = {
|
||||
/** Asset ID */
|
||||
assetId: string;
|
||||
/** Confidence score of the bounding box */
|
||||
boxScore: number;
|
||||
/** OCR entry ID */
|
||||
id: string;
|
||||
/** Whether the OCR entry is visible */
|
||||
isVisible: boolean;
|
||||
/** Recognized text content */
|
||||
text: string;
|
||||
/** Confidence score of the recognized text */
|
||||
textScore: number;
|
||||
/** Top-left X coordinate (normalized 0–1) */
|
||||
x1: number;
|
||||
/** Top-right X coordinate (normalized 0–1) */
|
||||
x2: number;
|
||||
/** Bottom-right X coordinate (normalized 0–1) */
|
||||
x3: number;
|
||||
/** Bottom-left X coordinate (normalized 0–1) */
|
||||
x4: number;
|
||||
/** Top-left Y coordinate (normalized 0–1) */
|
||||
y1: number;
|
||||
/** Top-right Y coordinate (normalized 0–1) */
|
||||
y2: number;
|
||||
/** Bottom-right Y coordinate (normalized 0–1) */
|
||||
y3: number;
|
||||
/** Bottom-left Y coordinate (normalized 0–1) */
|
||||
y4: number;
|
||||
};
|
||||
export type SyncAssetV1 = {
|
||||
/** Checksum */
|
||||
checksum: string;
|
||||
@@ -4302,6 +4271,82 @@ 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
|
||||
*/
|
||||
@@ -4518,7 +4563,7 @@ export function resolveDuplicates({ duplicateResolveDto }: {
|
||||
})));
|
||||
}
|
||||
/**
|
||||
* Delete a duplicate
|
||||
* Dismiss a duplicate group
|
||||
*/
|
||||
export function deleteDuplicate({ id }: {
|
||||
id: string;
|
||||
@@ -7136,6 +7181,7 @@ export enum JobName {
|
||||
LibrarySyncFilesQueueAll = "LibrarySyncFilesQueueAll",
|
||||
LibrarySyncFiles = "LibrarySyncFiles",
|
||||
LibraryScanQueueAll = "LibraryScanQueueAll",
|
||||
HlsSessionCleanup = "HlsSessionCleanup",
|
||||
MemoryCleanup = "MemoryCleanup",
|
||||
MemoryGenerate = "MemoryGenerate",
|
||||
NotificationsCleanup = "NotificationsCleanup",
|
||||
@@ -7192,8 +7238,6 @@ export enum SyncEntityType {
|
||||
AssetEditDeleteV1 = "AssetEditDeleteV1",
|
||||
AssetMetadataV1 = "AssetMetadataV1",
|
||||
AssetMetadataDeleteV1 = "AssetMetadataDeleteV1",
|
||||
AssetOcrV1 = "AssetOcrV1",
|
||||
AssetOcrDeleteV1 = "AssetOcrDeleteV1",
|
||||
PartnerV1 = "PartnerV1",
|
||||
PartnerDeleteV1 = "PartnerDeleteV1",
|
||||
PartnerAssetV1 = "PartnerAssetV1",
|
||||
@@ -7254,7 +7298,6 @@ export enum SyncRequestType {
|
||||
AssetExifsV1 = "AssetExifsV1",
|
||||
AssetEditsV1 = "AssetEditsV1",
|
||||
AssetMetadataV1 = "AssetMetadataV1",
|
||||
AssetOcrV1 = "AssetOcrV1",
|
||||
AuthUsersV1 = "AuthUsersV1",
|
||||
MemoriesV1 = "MemoriesV1",
|
||||
MemoryToAssetsV1 = "MemoryToAssetsV1",
|
||||
|
||||
Generated
+30
@@ -789,6 +789,12 @@ 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.1
|
||||
@@ -6739,6 +6745,9 @@ 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:
|
||||
@@ -8060,6 +8069,12 @@ 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
|
||||
@@ -9091,6 +9106,9 @@ 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'}
|
||||
@@ -19347,6 +19365,8 @@ snapshots:
|
||||
|
||||
csstype@3.2.3: {}
|
||||
|
||||
custom-media-element@1.4.6: {}
|
||||
|
||||
cytoscape-cose-bilkent@4.1.0(cytoscape@3.33.1):
|
||||
dependencies:
|
||||
cose-base: 1.0.3
|
||||
@@ -21018,6 +21038,14 @@ snapshots:
|
||||
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
|
||||
@@ -22188,6 +22216,8 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- react
|
||||
|
||||
media-tracks@0.3.5: {}
|
||||
|
||||
media-typer@0.3.0: {}
|
||||
|
||||
media-typer@1.1.0: {}
|
||||
|
||||
@@ -45,6 +45,9 @@ export type SystemConfig = {
|
||||
accel: TranscodeHardwareAcceleration;
|
||||
accelDecode: boolean;
|
||||
tonemap: ToneMapping;
|
||||
realtime: {
|
||||
enabled: boolean;
|
||||
};
|
||||
};
|
||||
job: Record<ConcurrentQueueName, { concurrency: number }>;
|
||||
logging: {
|
||||
@@ -224,6 +227,9 @@ export const defaults = Object.freeze<SystemConfig>({
|
||||
tonemap: ToneMapping.Hable,
|
||||
accel: TranscodeHardwareAcceleration.Disabled,
|
||||
accelDecode: false,
|
||||
realtime: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
job: {
|
||||
[QueueName.BackgroundTask]: { concurrency: 5 },
|
||||
|
||||
+38
-1
@@ -1,7 +1,15 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { SemVer } from 'semver';
|
||||
import { ApiTag, AudioCodec, DatabaseExtension, ExifOrientation, VectorIndex } from 'src/enum';
|
||||
import {
|
||||
ApiTag,
|
||||
AudioCodec,
|
||||
DatabaseExtension,
|
||||
ExifOrientation,
|
||||
TranscodeHardwareAcceleration,
|
||||
VectorIndex,
|
||||
VideoCodec,
|
||||
} from 'src/enum';
|
||||
|
||||
export const IMMICH_SERVER_START = 'Immich Server is listening';
|
||||
|
||||
@@ -203,3 +211,32 @@ 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;
|
||||
|
||||
@@ -41,8 +41,8 @@ export class DuplicateController {
|
||||
@Authenticated({ permission: Permission.DuplicateDelete })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@Endpoint({
|
||||
summary: 'Delete a duplicate',
|
||||
description: 'Delete a single duplicate asset specified by its ID.',
|
||||
summary: 'Dismiss a duplicate group',
|
||||
description: 'Dismiss a duplicate group by its ID, unlinking all assets in the group without deleting them.',
|
||||
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
|
||||
})
|
||||
deleteDuplicate(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
|
||||
|
||||
@@ -35,6 +35,7 @@ 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';
|
||||
|
||||
@@ -76,6 +77,7 @@ export const controllers = [
|
||||
TrashController,
|
||||
UserAdminController,
|
||||
UserController,
|
||||
VideoStreamController,
|
||||
ViewController,
|
||||
WorkflowController,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { MoveRepository } from 'src/repositories/move.repository';
|
||||
import { PersonRepository } from 'src/repositories/person.repository';
|
||||
import { StorageRepository } from 'src/repositories/storage.repository';
|
||||
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
|
||||
import { VideoInterfaces } from 'src/types';
|
||||
import { getAssetFile } from 'src/utils/asset.util';
|
||||
import { getConfig } from 'src/utils/config';
|
||||
|
||||
@@ -34,6 +35,10 @@ 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;
|
||||
@@ -124,6 +129,14 @@ 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`);
|
||||
}
|
||||
@@ -299,6 +312,11 @@ export class StorageCore {
|
||||
return this.storageRepository.removeEmptyDirs(StorageCore.getBaseFolder(folder));
|
||||
}
|
||||
|
||||
async getVideoInterfaces(): Promise<VideoInterfaces> {
|
||||
const [dri, mali] = await Promise.all([this.getDevices(), this.hasMaliOpenCL()]);
|
||||
return { dri, mali };
|
||||
}
|
||||
|
||||
private savePath(pathType: PathType, id: string, newPath: string) {
|
||||
switch (pathType) {
|
||||
case AssetPathType.Original: {
|
||||
@@ -330,4 +348,26 @@ export class StorageCore {
|
||||
static getTempPathInDir(dir: string): string {
|
||||
return join(dir, `${randomUUID()}.tmp`);
|
||||
}
|
||||
|
||||
private async getDevices() {
|
||||
try {
|
||||
return await this.storageRepository.readdir('/dev/dri');
|
||||
} catch {
|
||||
this.logger.debug('No devices found in /dev/dri.');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async hasMaliOpenCL() {
|
||||
try {
|
||||
const [maliIcdStat, maliDeviceStat] = await Promise.all([
|
||||
this.storageRepository.stat('/etc/OpenCL/vendors/mali.icd'),
|
||||
this.storageRepository.stat('/dev/mali0'),
|
||||
]);
|
||||
return maliIcdStat.isFile() && maliDeviceStat.isCharacterDevice();
|
||||
} catch {
|
||||
this.logger.debug('OpenCL not available for transcoding, so RKMPP acceleration will use CPU tonemapping');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,23 +426,6 @@ export const columns = {
|
||||
'asset_exif.rating',
|
||||
'asset_exif.fps',
|
||||
],
|
||||
syncAssetOcr: [
|
||||
'asset_ocr.id',
|
||||
'asset_ocr.assetId',
|
||||
'asset_ocr.x1',
|
||||
'asset_ocr.y1',
|
||||
'asset_ocr.x2',
|
||||
'asset_ocr.y2',
|
||||
'asset_ocr.x3',
|
||||
'asset_ocr.y3',
|
||||
'asset_ocr.x4',
|
||||
'asset_ocr.y4',
|
||||
'asset_ocr.text',
|
||||
'asset_ocr.boxScore',
|
||||
'asset_ocr.textScore',
|
||||
'asset_ocr.updateId',
|
||||
'asset_ocr.isVisible',
|
||||
],
|
||||
syncAssetEdit: [
|
||||
'asset_edit.id',
|
||||
'asset_edit.assetId',
|
||||
|
||||
@@ -137,6 +137,7 @@ 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' });
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
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) {}
|
||||
@@ -408,41 +408,6 @@ class SyncMemoryDeleteV1 extends createZodDto(SyncMemoryDeleteV1Schema) {}
|
||||
class SyncMemoryAssetV1 extends createZodDto(SyncMemoryAssetV1Schema) {}
|
||||
@ExtraModel()
|
||||
class SyncMemoryAssetDeleteV1 extends createZodDto(SyncMemoryAssetDeleteV1Schema) {}
|
||||
|
||||
const SyncAssetOcrV1Schema = z
|
||||
.object({
|
||||
id: z.string().describe('OCR entry ID'),
|
||||
assetId: z.string().describe('Asset ID'),
|
||||
|
||||
x1: z.number().meta({ format: 'double' }).describe('Top-left X coordinate (normalized 0–1)'),
|
||||
y1: z.number().meta({ format: 'double' }).describe('Top-left Y coordinate (normalized 0–1)'),
|
||||
x2: z.number().meta({ format: 'double' }).describe('Top-right X coordinate (normalized 0–1)'),
|
||||
y2: z.number().meta({ format: 'double' }).describe('Top-right Y coordinate (normalized 0–1)'),
|
||||
x3: z.number().meta({ format: 'double' }).describe('Bottom-right X coordinate (normalized 0–1)'),
|
||||
y3: z.number().meta({ format: 'double' }).describe('Bottom-right Y coordinate (normalized 0–1)'),
|
||||
x4: z.number().meta({ format: 'double' }).describe('Bottom-left X coordinate (normalized 0–1)'),
|
||||
y4: z.number().meta({ format: 'double' }).describe('Bottom-left Y coordinate (normalized 0–1)'),
|
||||
|
||||
boxScore: z.number().meta({ format: 'double' }).describe('Confidence score of the bounding box'),
|
||||
textScore: z.number().meta({ format: 'double' }).describe('Confidence score of the recognized text'),
|
||||
text: z.string().describe('Recognized text content'),
|
||||
isVisible: z.boolean().describe('Whether the OCR entry is visible'),
|
||||
})
|
||||
.meta({ id: 'SyncAssetOcrV1' });
|
||||
|
||||
const SyncAssetOcrDeleteV1Schema = z
|
||||
.object({
|
||||
id: z.string().describe('Audit row ID of the deleted OCR entry'),
|
||||
assetId: z.string().describe('Original asset ID of the deleted OCR entry'),
|
||||
deletedAt: isoDatetimeToDate.describe('Timestamp when the OCR entry was deleted'),
|
||||
})
|
||||
.meta({ id: 'SyncAssetOcrDeleteV1' });
|
||||
|
||||
@ExtraModel()
|
||||
class SyncAssetOcrV1 extends createZodDto(SyncAssetOcrV1Schema) {}
|
||||
|
||||
@ExtraModel()
|
||||
class SyncAssetOcrDeleteV1 extends createZodDto(SyncAssetOcrDeleteV1Schema) {}
|
||||
@ExtraModel()
|
||||
class SyncStackV1 extends createZodDto(SyncStackV1Schema) {}
|
||||
@ExtraModel()
|
||||
@@ -479,8 +444,6 @@ export type SyncItem = {
|
||||
[SyncEntityType.AssetMetadataV1]: SyncAssetMetadataV1;
|
||||
[SyncEntityType.AssetMetadataDeleteV1]: SyncAssetMetadataDeleteV1;
|
||||
[SyncEntityType.AssetExifV1]: SyncAssetExifV1;
|
||||
[SyncEntityType.AssetOcrV1]: SyncAssetOcrV1;
|
||||
[SyncEntityType.AssetOcrDeleteV1]: SyncAssetOcrDeleteV1;
|
||||
[SyncEntityType.AssetEditV1]: SyncAssetEditV1;
|
||||
[SyncEntityType.AssetEditDeleteV1]: SyncAssetEditDeleteV1;
|
||||
[SyncEntityType.PartnerAssetV2]: SyncAssetV2;
|
||||
|
||||
@@ -83,6 +83,11 @@ 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' });
|
||||
|
||||
|
||||
+4
-8
@@ -445,11 +445,7 @@ export enum VideoCodec {
|
||||
|
||||
export const VideoCodecSchema = z.enum(VideoCodec).describe('Target video codec').meta({ id: 'VideoCodec' });
|
||||
|
||||
export enum VideoSegmentCodec {
|
||||
Av1 = 'av1',
|
||||
Hevc = 'hevc',
|
||||
H264 = 'h264',
|
||||
}
|
||||
export type VideoSegmentCodec = VideoCodec.Av1 | VideoCodec.Hevc | VideoCodec.H264;
|
||||
|
||||
export enum AudioCodec {
|
||||
Mp3 = 'mp3',
|
||||
@@ -818,6 +814,8 @@ export enum JobName {
|
||||
LibrarySyncFiles = 'LibrarySyncFiles',
|
||||
LibraryScanQueueAll = 'LibraryScanQueueAll',
|
||||
|
||||
HlsSessionCleanup = 'HlsSessionCleanup',
|
||||
|
||||
MemoryCleanup = 'MemoryCleanup',
|
||||
MemoryGenerate = 'MemoryGenerate',
|
||||
|
||||
@@ -910,6 +908,7 @@ export enum DatabaseLock {
|
||||
MaintenanceOperation = 621,
|
||||
MemoryCreation = 777,
|
||||
VersionCheck = 800,
|
||||
HlsSessionCleanup = 850,
|
||||
}
|
||||
|
||||
export enum MaintenanceAction {
|
||||
@@ -943,7 +942,6 @@ export enum SyncRequestType {
|
||||
AssetExifsV1 = 'AssetExifsV1',
|
||||
AssetEditsV1 = 'AssetEditsV1',
|
||||
AssetMetadataV1 = 'AssetMetadataV1',
|
||||
AssetOcrV1 = 'AssetOcrV1',
|
||||
AuthUsersV1 = 'AuthUsersV1',
|
||||
MemoriesV1 = 'MemoriesV1',
|
||||
MemoryToAssetsV1 = 'MemoryToAssetsV1',
|
||||
@@ -982,8 +980,6 @@ export enum SyncEntityType {
|
||||
AssetEditDeleteV1 = 'AssetEditDeleteV1',
|
||||
AssetMetadataV1 = 'AssetMetadataV1',
|
||||
AssetMetadataDeleteV1 = 'AssetMetadataDeleteV1',
|
||||
AssetOcrV1 = 'AssetOcrV1',
|
||||
AssetOcrDeleteV1 = 'AssetOcrDeleteV1',
|
||||
|
||||
PartnerV1 = 'PartnerV1',
|
||||
PartnerDeleteV1 = 'PartnerDeleteV1',
|
||||
|
||||
@@ -575,48 +575,6 @@ where
|
||||
order by
|
||||
"asset_metadata"."updateId" asc
|
||||
|
||||
-- SyncRepository.assetOcr.getDeletes
|
||||
select
|
||||
"asset_ocr_audit"."id",
|
||||
"asset_ocr_audit"."assetId",
|
||||
"asset_ocr_audit"."deletedAt"
|
||||
from
|
||||
"asset_ocr_audit" as "asset_ocr_audit"
|
||||
left join "asset" on "asset"."id" = "asset_ocr_audit"."assetId"
|
||||
where
|
||||
"asset_ocr_audit"."id" < $1
|
||||
and "asset_ocr_audit"."id" > $2
|
||||
and "asset"."ownerId" = $3
|
||||
order by
|
||||
"asset_ocr_audit"."id" asc
|
||||
|
||||
-- SyncRepository.assetOcr.getUpserts
|
||||
select
|
||||
"asset_ocr"."id",
|
||||
"asset_ocr"."assetId",
|
||||
"asset_ocr"."x1",
|
||||
"asset_ocr"."y1",
|
||||
"asset_ocr"."x2",
|
||||
"asset_ocr"."y2",
|
||||
"asset_ocr"."x3",
|
||||
"asset_ocr"."y3",
|
||||
"asset_ocr"."x4",
|
||||
"asset_ocr"."y4",
|
||||
"asset_ocr"."text",
|
||||
"asset_ocr"."boxScore",
|
||||
"asset_ocr"."textScore",
|
||||
"asset_ocr"."updateId",
|
||||
"asset_ocr"."isVisible"
|
||||
from
|
||||
"asset_ocr" as "asset_ocr"
|
||||
inner join "asset" on "asset"."id" = "asset_ocr"."assetId"
|
||||
where
|
||||
"asset_ocr"."updateId" < $1
|
||||
and "asset_ocr"."updateId" > $2
|
||||
and "asset"."ownerId" = $3
|
||||
order by
|
||||
"asset_ocr"."updateId" asc
|
||||
|
||||
-- SyncRepository.authUser.getUpserts
|
||||
select
|
||||
"id",
|
||||
|
||||
@@ -7,6 +7,7 @@ from
|
||||
"video_stream_session"
|
||||
where
|
||||
"id" = $1
|
||||
and "expiresAt" > $2
|
||||
|
||||
-- VideoStreamRepository.getVariant
|
||||
select
|
||||
@@ -27,11 +28,13 @@ where
|
||||
|
||||
-- VideoStreamRepository.getExpiredSessions
|
||||
select
|
||||
"id"
|
||||
"video_stream_session"."id",
|
||||
"asset"."ownerId"
|
||||
from
|
||||
"video_stream_session"
|
||||
inner join "asset" on "asset"."id" = "video_stream_session"."assetId"
|
||||
where
|
||||
"expiresAt" <= $1
|
||||
"video_stream_session"."expiresAt" <= $1
|
||||
|
||||
-- VideoStreamRepository.extendSession
|
||||
update "video_stream_session"
|
||||
@@ -44,3 +47,253 @@ 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,6 +92,14 @@ 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 }];
|
||||
};
|
||||
|
||||
@@ -274,23 +274,23 @@ export class MediaRepository {
|
||||
index: stream.index,
|
||||
height,
|
||||
width: dar ? Math.round(height * dar) : this.parseInt(stream.width),
|
||||
codecName: stream.codec_name === 'h265' ? 'hevc' : stream.codec_name,
|
||||
profile: this.parseVideoProfile(stream.codec_name, stream.profile as string | undefined),
|
||||
codecName: stream.codec_name === 'h265' ? 'hevc' : (stream.codec_name ?? null),
|
||||
profile: this.parseVideoProfile(stream.codec_name, stream.profile as string | undefined) ?? null,
|
||||
level: this.parseOptionalInt(stream.level),
|
||||
frameCount: this.parseInt(options?.countFrames ? stream.nb_read_packets : stream.nb_frames),
|
||||
frameRate: this.parseFrameRate(stream.avg_frame_rate ?? stream.r_frame_rate),
|
||||
timeBase: this.parseRational(stream.time_base)?.den,
|
||||
timeBase: this.parseRational(stream.time_base)?.den ?? null,
|
||||
rotation: this.parseInt(stream.rotation),
|
||||
bitrate: this.parseInt(stream.bit_rate),
|
||||
pixelFormat: stream.pix_fmt || 'yuv420p',
|
||||
colorPrimaries: this.parseEnum(ColorPrimaries, stream.color_primaries) ?? ColorPrimaries.Unknown,
|
||||
colorMatrix: this.parseEnum(ColorMatrix, stream.color_space) ?? ColorMatrix.Unknown,
|
||||
colorTransfer: this.parseEnum(ColorTransfer, stream.color_transfer) ?? ColorTransfer.Unknown,
|
||||
dvProfile: this.parseOptionalInt(stream.dv_profile) as DvProfile | undefined,
|
||||
dvProfile: this.parseOptionalInt(stream.dv_profile) as DvProfile | null,
|
||||
dvLevel: this.parseOptionalInt(stream.dv_level),
|
||||
dvBlSignalCompatibilityId: this.parseOptionalInt(stream.dv_bl_signal_compatibility_id) as
|
||||
| DvSignalCompatibility
|
||||
| undefined,
|
||||
dvBlSignalCompatibilityId: this.parseOptionalInt(
|
||||
stream.dv_bl_signal_compatibility_id,
|
||||
) as DvSignalCompatibility | null,
|
||||
};
|
||||
}),
|
||||
audioStreams: results.streams
|
||||
@@ -298,9 +298,9 @@ export class MediaRepository {
|
||||
.sort((a, b) => this.compareStreams(a, b))
|
||||
.map((stream) => ({
|
||||
index: stream.index,
|
||||
codecName: stream.codec_name,
|
||||
codecName: stream.codec_name ?? null,
|
||||
profile:
|
||||
stream.codec_name === 'aac' ? this.parseEnum(AacProfile, stream.profile as string | undefined) : undefined,
|
||||
stream.codec_name === 'aac' ? this.parseEnum(AacProfile, stream.profile as string | undefined) : null,
|
||||
bitrate: this.parseInt(stream.bit_rate),
|
||||
})),
|
||||
};
|
||||
@@ -449,29 +449,29 @@ export class MediaRepository {
|
||||
return Number.parseFloat(value as string) || 0;
|
||||
}
|
||||
|
||||
private parseOptionalInt(value: string | number | undefined): number | undefined {
|
||||
private parseOptionalInt(value: string | number | undefined): number | null {
|
||||
const parsed = Number.parseInt(value as string);
|
||||
return Number.isNaN(parsed) ? undefined : parsed;
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
|
||||
private parseEnum<E extends Record<string, number | string>>(enumObj: E, value?: string) {
|
||||
return value ? (enumObj[pascalCase(value)] as Extract<E[keyof E], number> | undefined) : undefined;
|
||||
return value ? ((enumObj[pascalCase(value)] as Extract<E[keyof E], number> | undefined) ?? null) : null;
|
||||
}
|
||||
|
||||
/** Parse a rational like "60000/1001" or "1/600" into `{ num, den }`. */
|
||||
private parseRational(value: string | undefined): { num: number; den: number } | undefined {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
const [num, den = 1] = value.split('/').map(Number);
|
||||
if (num && den) {
|
||||
return { num, den };
|
||||
private parseRational(value: string | undefined): { num: number; den: number } | null {
|
||||
if (value) {
|
||||
const [num, den = 1] = value.split('/').map(Number);
|
||||
if (num && den) {
|
||||
return { num, den };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private parseFrameRate(value: string | undefined): number | undefined {
|
||||
private parseFrameRate(value: string | undefined): number | null {
|
||||
const r = this.parseRational(value);
|
||||
return r ? r.num / r.den : undefined;
|
||||
return r ? r.num / r.den : null;
|
||||
}
|
||||
|
||||
private getDar(dar: string | undefined): number {
|
||||
@@ -498,6 +498,7 @@ export class MediaRepository {
|
||||
return this.parseEnum(Av1Profile, profile);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private compareStreams(a: FfprobeStream, b: FfprobeStream): number {
|
||||
@@ -508,18 +509,43 @@ 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 delta = (pkt.pts - firstPts) * slotsPerTick - nextPts + pkt.duration * slotsPerTick;
|
||||
const nb = delta < -1.1 ? 0 : delta > 1.1 ? Math.round(delta) : 1;
|
||||
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);
|
||||
}
|
||||
}
|
||||
outputFrames += nb;
|
||||
nextPts += nb;
|
||||
history[2] = history[1];
|
||||
history[1] = history[0];
|
||||
history[0] = nbPrev;
|
||||
}
|
||||
return outputFrames;
|
||||
const median = history.sort((a, b) => a - b)[1];
|
||||
return outputFrames + median;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ChildProcessWithoutNullStreams, fork, spawn, SpawnOptionsWithoutStdio } from 'node:child_process';
|
||||
import { fork, spawn, SpawnOptionsWithoutStdio } from 'node:child_process';
|
||||
import { Duplex } from 'node:stream';
|
||||
|
||||
@Injectable()
|
||||
export class ProcessRepository {
|
||||
spawn(command: string, args?: readonly string[], options?: SpawnOptionsWithoutStdio): ChildProcessWithoutNullStreams {
|
||||
return spawn(command, args, options);
|
||||
}
|
||||
spawn = spawn;
|
||||
|
||||
spawnDuplexStream(command: string, args?: readonly string[], options?: SpawnOptionsWithoutStdio): Duplex {
|
||||
let stdinClosed = false;
|
||||
|
||||
@@ -2,7 +2,15 @@ import { Injectable } from '@nestjs/common';
|
||||
import archiver from 'archiver';
|
||||
import chokidar, { ChokidarOptions } from 'chokidar';
|
||||
import { escapePath, glob, globStream } from 'fast-glob';
|
||||
import { constants, createReadStream, createWriteStream, existsSync, mkdirSync, ReadOptionsWithBuffer } from 'node:fs';
|
||||
import {
|
||||
constants,
|
||||
createReadStream,
|
||||
createWriteStream,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
ReadOptionsWithBuffer,
|
||||
watch,
|
||||
} from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { PassThrough, Readable, Writable } from 'node:stream';
|
||||
@@ -258,6 +266,8 @@ 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(',')}}`;
|
||||
|
||||
@@ -56,7 +56,6 @@ export class SyncRepository {
|
||||
assetEdit: AssetEditSync;
|
||||
assetFace: AssetFaceSync;
|
||||
assetMetadata: AssetMetadataSync;
|
||||
assetOcr: AssetOcrSync;
|
||||
authUser: AuthUserSync;
|
||||
memory: MemorySync;
|
||||
memoryToAsset: MemoryToAssetSync;
|
||||
@@ -80,7 +79,6 @@ export class SyncRepository {
|
||||
this.assetEdit = new AssetEditSync(this.db);
|
||||
this.assetFace = new AssetFaceSync(this.db);
|
||||
this.assetMetadata = new AssetMetadataSync(this.db);
|
||||
this.assetOcr = new AssetOcrSync(this.db);
|
||||
this.authUser = new AuthUserSync(this.db);
|
||||
this.memory = new MemorySync(this.db);
|
||||
this.memoryToAsset = new MemoryToAssetSync(this.db);
|
||||
@@ -769,27 +767,3 @@ class AssetMetadataSync extends BaseSync {
|
||||
.stream();
|
||||
}
|
||||
}
|
||||
|
||||
class AssetOcrSync extends BaseSync {
|
||||
@GenerateSql({ params: [dummyQueryOptions, DummyValue.UUID], stream: true })
|
||||
getDeletes(options: SyncQueryOptions, userId: string) {
|
||||
return this.auditQuery('asset_ocr_audit', options)
|
||||
.select(['asset_ocr_audit.id', 'asset_ocr_audit.assetId', 'asset_ocr_audit.deletedAt'])
|
||||
.leftJoin('asset', 'asset.id', 'asset_ocr_audit.assetId')
|
||||
.where('asset.ownerId', '=', userId)
|
||||
.stream();
|
||||
}
|
||||
|
||||
cleanupAuditTable(daysAgo: number) {
|
||||
return this.auditCleanup('asset_ocr_audit', daysAgo);
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [dummyQueryOptions, DummyValue.UUID], stream: true })
|
||||
getUpserts(options: SyncQueryOptions, userId: string) {
|
||||
return this.upsertQuery('asset_ocr', options)
|
||||
.select(columns.syncAssetOcr)
|
||||
.innerJoin('asset', 'asset.id', 'asset_ocr.assetId')
|
||||
.where('asset.ownerId', '=', userId)
|
||||
.stream();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
VideoStreamSessionTable,
|
||||
VideoStreamVariantTable,
|
||||
} from 'src/schema/tables/video-stream.table';
|
||||
import { withAudioStream, withVideoFormat, withVideoPackets, withVideoStream } from 'src/utils/database';
|
||||
|
||||
@Injectable()
|
||||
export class VideoStreamRepository {
|
||||
@@ -27,7 +28,12 @@ export class VideoStreamRepository {
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getSession(id: string) {
|
||||
return this.db.selectFrom('video_stream_session').selectAll().where('id', '=', id).executeTakeFirst();
|
||||
return this.db
|
||||
.selectFrom('video_stream_session')
|
||||
.selectAll()
|
||||
.where('id', '=', id)
|
||||
.where('expiresAt', '>', new Date())
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
@@ -47,7 +53,12 @@ export class VideoStreamRepository {
|
||||
|
||||
@GenerateSql()
|
||||
getExpiredSessions() {
|
||||
return this.db.selectFrom('video_stream_session').select(['id']).where('expiresAt', '<=', new Date()).execute();
|
||||
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();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.DATE] })
|
||||
@@ -59,4 +70,50 @@ 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,16 @@ import { AppRestartEvent, ArgsOf, EventRepository } from 'src/repositories/event
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { handlePromiseError } from 'src/utils/misc';
|
||||
|
||||
export const serverEvents = ['ConfigUpdate', 'AppRestart'] as const;
|
||||
export const serverEvents = [
|
||||
'ConfigUpdate',
|
||||
'AppRestart',
|
||||
'HlsSegmentRequest',
|
||||
'HlsSegmentResult',
|
||||
'HlsHeartbeat',
|
||||
'HlsSessionRequest',
|
||||
'HlsSessionResult',
|
||||
'HlsSessionEnd',
|
||||
] as const;
|
||||
export type ServerEvents = (typeof serverEvents)[number];
|
||||
|
||||
export interface ClientEventMap {
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import { registerEnum } from '@immich/sql-tools';
|
||||
import {
|
||||
AlbumUserRole,
|
||||
AssetStatus,
|
||||
AssetVisibility,
|
||||
ChecksumAlgorithm,
|
||||
SourceType,
|
||||
VideoSegmentCodec,
|
||||
} from 'src/enum';
|
||||
import { AlbumUserRole, AssetStatus, AssetVisibility, ChecksumAlgorithm, SourceType, VideoCodec } from 'src/enum';
|
||||
|
||||
export const album_user_role_enum = registerEnum({
|
||||
name: 'album_user_role_enum',
|
||||
@@ -35,5 +28,5 @@ export const asset_checksum_algorithm_enum = registerEnum({
|
||||
|
||||
export const video_stream_variant_codec_enum = registerEnum({
|
||||
name: 'video_stream_variant_codec_enum',
|
||||
values: Object.values(VideoSegmentCodec),
|
||||
values: [VideoCodec.Av1, VideoCodec.Hevc, VideoCodec.H264],
|
||||
});
|
||||
|
||||
@@ -287,16 +287,3 @@ export const asset_edit_audit = registerFunction({
|
||||
RETURN NULL;
|
||||
END`,
|
||||
});
|
||||
|
||||
export const asset_ocr_delete_audit = registerFunction({
|
||||
name: 'asset_ocr_delete_audit',
|
||||
returnType: 'TRIGGER',
|
||||
language: 'PLPGSQL',
|
||||
body: `
|
||||
BEGIN
|
||||
INSERT INTO asset_ocr_audit ("assetId")
|
||||
SELECT "assetId"
|
||||
FROM OLD;
|
||||
RETURN NULL;
|
||||
END`,
|
||||
});
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
asset_delete_audit,
|
||||
asset_face_audit,
|
||||
asset_metadata_audit,
|
||||
asset_ocr_delete_audit,
|
||||
f_concat_ws,
|
||||
f_unaccent,
|
||||
immich_uuid_v7,
|
||||
@@ -44,7 +43,6 @@ import { AssetFileTable } from 'src/schema/tables/asset-file.table';
|
||||
import { AssetJobStatusTable } from 'src/schema/tables/asset-job-status.table';
|
||||
import { AssetMetadataAuditTable } from 'src/schema/tables/asset-metadata-audit.table';
|
||||
import { AssetMetadataTable } from 'src/schema/tables/asset-metadata.table';
|
||||
import { AssetOcrAuditTable } from 'src/schema/tables/asset-ocr-audit.table';
|
||||
import { AssetOcrTable } from 'src/schema/tables/asset-ocr.table';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
import { FaceSearchTable } from 'src/schema/tables/face-search.table';
|
||||
@@ -107,7 +105,6 @@ export class ImmichDatabase {
|
||||
AssetMetadataAuditTable,
|
||||
AssetJobStatusTable,
|
||||
AssetOcrTable,
|
||||
AssetOcrAuditTable,
|
||||
AssetTable,
|
||||
AssetFileTable,
|
||||
AssetExifTable,
|
||||
@@ -171,7 +168,6 @@ export class ImmichDatabase {
|
||||
user_metadata_audit,
|
||||
asset_metadata_audit,
|
||||
asset_face_audit,
|
||||
asset_ocr_delete_audit,
|
||||
];
|
||||
|
||||
enum = [album_user_role_enum, assets_status_enum, asset_face_source_type, asset_visibility_enum];
|
||||
@@ -209,7 +205,6 @@ export interface DB {
|
||||
asset_metadata_audit: AssetMetadataAuditTable;
|
||||
asset_job_status: AssetJobStatusTable;
|
||||
asset_ocr: AssetOcrTable;
|
||||
asset_ocr_audit: AssetOcrAuditTable;
|
||||
asset_audio: AssetAudioTable;
|
||||
asset_video: AssetVideoTable;
|
||||
asset_keyframe: AssetKeyframeTable;
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`CREATE OR REPLACE FUNCTION asset_edit_delete()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE PLPGSQL
|
||||
AS $$
|
||||
BEGIN
|
||||
UPDATE asset
|
||||
SET "isEdited" = false
|
||||
FROM deleted_edit
|
||||
WHERE asset.id = deleted_edit."assetId" AND asset."isEdited"
|
||||
AND NOT EXISTS (SELECT FROM asset_edit edit WHERE edit."assetId" = asset.id);
|
||||
RETURN NULL;
|
||||
END
|
||||
$$;`.execute(db);
|
||||
await sql`CREATE OR REPLACE FUNCTION asset_ocr_delete_audit()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE PLPGSQL
|
||||
AS $$
|
||||
BEGIN
|
||||
INSERT INTO asset_ocr_audit ("assetId")
|
||||
SELECT "assetId"
|
||||
FROM OLD;
|
||||
RETURN NULL;
|
||||
END
|
||||
$$;`.execute(db);
|
||||
await sql`CREATE TABLE "asset_ocr_audit" (
|
||||
"id" uuid NOT NULL DEFAULT immich_uuid_v7(),
|
||||
"assetId" uuid NOT NULL,
|
||||
"deletedAt" timestamp with time zone NOT NULL DEFAULT clock_timestamp(),
|
||||
CONSTRAINT "asset_ocr_audit_pkey" PRIMARY KEY ("id")
|
||||
);`.execute(db);
|
||||
await sql`CREATE INDEX "asset_ocr_audit_assetId_idx" ON "asset_ocr_audit" ("assetId");`.execute(db);
|
||||
await sql`CREATE INDEX "asset_ocr_audit_deletedAt_idx" ON "asset_ocr_audit" ("deletedAt");`.execute(db);
|
||||
await sql`ALTER TABLE "asset_ocr" ADD "updateId" uuid NOT NULL DEFAULT immich_uuid_v7();`.execute(db);
|
||||
await sql`CREATE INDEX "asset_ocr_updateId_idx" ON "asset_ocr" ("updateId");`.execute(db);
|
||||
await sql`CREATE OR REPLACE TRIGGER "asset_ocr_delete_audit"
|
||||
AFTER DELETE ON "asset_ocr"
|
||||
REFERENCING OLD TABLE AS "old"
|
||||
FOR EACH STATEMENT
|
||||
WHEN (pg_trigger_depth() = 0)
|
||||
EXECUTE FUNCTION asset_ocr_delete_audit();`.execute(db);
|
||||
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('function_asset_ocr_delete_audit', '{"type":"function","name":"asset_ocr_delete_audit","sql":"CREATE OR REPLACE FUNCTION asset_ocr_delete_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO asset_ocr_audit (\\"assetId\\")\\n SELECT \\"assetId\\"\\n FROM OLD;\\n RETURN NULL;\\n END\\n $$;"}'::jsonb);`.execute(db);
|
||||
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_asset_ocr_delete_audit', '{"type":"trigger","name":"asset_ocr_delete_audit","sql":"CREATE OR REPLACE TRIGGER \\"asset_ocr_delete_audit\\"\\n AFTER DELETE ON \\"asset_ocr\\"\\n REFERENCING OLD TABLE AS \\"old\\"\\n FOR EACH STATEMENT\\n WHEN (pg_trigger_depth() = 0)\\n EXECUTE FUNCTION asset_ocr_delete_audit();"}'::jsonb);`.execute(db);
|
||||
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"function","name":"asset_edit_delete","sql":"CREATE OR REPLACE FUNCTION asset_edit_delete()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n UPDATE asset\\n SET \\"isEdited\\" = false\\n FROM deleted_edit\\n WHERE asset.id = deleted_edit.\\"assetId\\" AND asset.\\"isEdited\\"\\n AND NOT EXISTS (SELECT FROM asset_edit edit WHERE edit.\\"assetId\\" = asset.id);\\n RETURN NULL;\\n END\\n $$;"}'::jsonb WHERE "name" = 'function_asset_edit_delete';`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await sql`DROP TRIGGER "asset_ocr_delete_audit" ON "asset_ocr";`.execute(db);
|
||||
await sql`DROP INDEX "asset_ocr_updateId_idx";`.execute(db);
|
||||
await sql`ALTER TABLE "asset_ocr" DROP COLUMN "updateId";`.execute(db);
|
||||
await sql`DROP TABLE "asset_ocr_audit";`.execute(db);
|
||||
await sql`DROP FUNCTION asset_ocr_delete_audit;`.execute(db);
|
||||
await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE OR REPLACE FUNCTION asset_edit_delete()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n UPDATE asset\\n SET \\"isEdited\\" = false\\n FROM deleted_edit\\n WHERE asset.id = deleted_edit.\\"assetId\\" AND asset.\\"isEdited\\" \\n AND NOT EXISTS (SELECT FROM asset_edit edit WHERE edit.\\"assetId\\" = asset.id);\\n RETURN NULL;\\n END\\n $$;","name":"asset_edit_delete","type":"function"}'::jsonb WHERE "name" = 'function_asset_edit_delete';`.execute(db);
|
||||
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'function_asset_ocr_delete_audit';`.execute(db);
|
||||
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_asset_ocr_delete_audit';`.execute(db);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { Column, CreateDateColumn, Generated, Table } from '@immich/sql-tools';
|
||||
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
|
||||
|
||||
@Table('asset_ocr_audit')
|
||||
export class AssetOcrAuditTable {
|
||||
@PrimaryGeneratedUuidV7Column()
|
||||
id!: Generated<string>;
|
||||
|
||||
@Column({ type: 'uuid', index: true })
|
||||
assetId!: string;
|
||||
|
||||
@CreateDateColumn({ default: () => 'clock_timestamp()', index: true })
|
||||
deletedAt!: Date;
|
||||
}
|
||||
@@ -1,22 +1,7 @@
|
||||
import {
|
||||
AfterDeleteTrigger,
|
||||
Column,
|
||||
ForeignKeyColumn,
|
||||
Generated,
|
||||
PrimaryGeneratedColumn,
|
||||
Table,
|
||||
} from '@immich/sql-tools';
|
||||
import { UpdateIdColumn } from 'src/decorators';
|
||||
import { asset_ocr_delete_audit } from 'src/schema/functions';
|
||||
import { Column, ForeignKeyColumn, Generated, PrimaryGeneratedColumn, Table } from '@immich/sql-tools';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
|
||||
@Table('asset_ocr')
|
||||
@AfterDeleteTrigger({
|
||||
scope: 'statement',
|
||||
function: asset_ocr_delete_audit,
|
||||
referencingOldTableAs: 'old',
|
||||
when: 'pg_trigger_depth() = 0',
|
||||
})
|
||||
export class AssetOcrTable {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: Generated<string>;
|
||||
@@ -60,7 +45,4 @@ export class AssetOcrTable {
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
isVisible!: Generated<boolean>;
|
||||
|
||||
@UpdateIdColumn({ index: true })
|
||||
updateId!: Generated<string>;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
|
||||
import { MapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AssetType, AssetVisibility, JobName, JobStatus } from 'src/enum';
|
||||
@@ -149,6 +150,36 @@ describe(DuplicateService.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should throw for an unknown or unauthorized group id', async () => {
|
||||
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set());
|
||||
await expect(sut.delete(authStub.admin, 'group-1')).rejects.toThrow(BadRequestException);
|
||||
expect(mocks.duplicateRepository.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should dismiss the duplicate group', async () => {
|
||||
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1']));
|
||||
mocks.duplicateRepository.delete.mockResolvedValue();
|
||||
await expect(sut.delete(authStub.admin, 'group-1')).resolves.toBeUndefined();
|
||||
expect(mocks.duplicateRepository.delete).toHaveBeenCalledWith(authStub.admin.user.id, 'group-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteAll', () => {
|
||||
it('should throw if any group id is unknown or unauthorized', async () => {
|
||||
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1']));
|
||||
await expect(sut.deleteAll(authStub.admin, { ids: ['group-1', 'group-2'] })).rejects.toThrow(BadRequestException);
|
||||
expect(mocks.duplicateRepository.deleteAll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should dismiss all duplicate groups', async () => {
|
||||
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1', 'group-2']));
|
||||
mocks.duplicateRepository.deleteAll.mockResolvedValue();
|
||||
await expect(sut.deleteAll(authStub.admin, { ids: ['group-1', 'group-2'] })).resolves.toBeUndefined();
|
||||
expect(mocks.duplicateRepository.deleteAll).toHaveBeenCalledWith(authStub.admin.user.id, ['group-1', 'group-2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolve', () => {
|
||||
it('should handle mixed success and failure', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
|
||||
@@ -82,10 +82,12 @@ export class DuplicateService extends BaseService {
|
||||
}
|
||||
|
||||
async delete(auth: AuthDto, id: string): Promise<void> {
|
||||
await this.requireAccess({ auth, permission: Permission.DuplicateDelete, ids: [id] });
|
||||
await this.duplicateRepository.delete(auth.user.id, id);
|
||||
}
|
||||
|
||||
async deleteAll(auth: AuthDto, dto: BulkIdsDto) {
|
||||
await this.requireAccess({ auth, permission: Permission.DuplicateDelete, ids: dto.ids });
|
||||
await this.duplicateRepository.deleteAll(auth.user.id, dto.ids);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
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:6
|
||||
#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:6
|
||||
#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:6
|
||||
#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:6
|
||||
#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:6
|
||||
#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:6
|
||||
#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
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=7000000,RESOLUTION=1440x2560,CODECS="av01.0.12M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||
${sessionId}/9/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=8000000,RESOLUTION=1440x2560,CODECS="hvc1.2.4.L150.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||
${sessionId}/10/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=16000000,RESOLUTION=1440x2560,CODECS="avc1.640032,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||
${sessionId}/11/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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
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,6 +11,7 @@ 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';
|
||||
@@ -39,6 +40,7 @@ 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';
|
||||
@@ -60,6 +62,7 @@ export const services = [
|
||||
DatabaseService,
|
||||
DownloadService,
|
||||
DuplicateService,
|
||||
HlsService,
|
||||
JobService,
|
||||
LibraryService,
|
||||
MaintenanceService,
|
||||
@@ -88,6 +91,7 @@ export const services = [
|
||||
TagService,
|
||||
TelemetryService,
|
||||
TimelineService,
|
||||
TranscodingService,
|
||||
TrashService,
|
||||
UserAdminService,
|
||||
UserService,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NotNull, ShallowDehydrateObject } from 'kysely';
|
||||
import { ShallowDehydrateObject } from 'kysely';
|
||||
import { OutputInfo } from 'sharp';
|
||||
import { SystemConfig } from 'src/config';
|
||||
import { Exif } from 'src/database';
|
||||
@@ -1937,7 +1937,7 @@ describe(MediaService.name, () => {
|
||||
|
||||
describe('handleVideoConversion', () => {
|
||||
let asset: ReturnType<typeof AssetFactory.create> & {
|
||||
videoStream: VideoStreamInfo & { timeBase: NotNull };
|
||||
videoStream: VideoStreamInfo & { timeBase: number };
|
||||
audioStream: AudioStreamInfo | null;
|
||||
format: VideoFormat;
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
AudioCodec,
|
||||
Colorspace,
|
||||
ImageFormat,
|
||||
ImmichWorker,
|
||||
JobName,
|
||||
JobStatus,
|
||||
QueueName,
|
||||
@@ -60,10 +61,9 @@ type ThumbnailAsset = NonNullable<Awaited<ReturnType<AssetJobRepository['getForG
|
||||
export class MediaService extends BaseService {
|
||||
videoInterfaces: VideoInterfaces = { dri: [], mali: false };
|
||||
|
||||
@OnEvent({ name: 'AppBootstrap' })
|
||||
@OnEvent({ name: 'AppBootstrap', workers: [ImmichWorker.Microservices] })
|
||||
async onBootstrap() {
|
||||
const [dri, mali] = await Promise.all([this.getDevices(), this.hasMaliOpenCL()]);
|
||||
this.videoInterfaces = { dri, mali };
|
||||
this.videoInterfaces = await this.storageCore.getVideoInterfaces();
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.AssetGenerateThumbnailsQueueAll, queue: QueueName.ThumbnailGeneration })
|
||||
@@ -789,28 +789,6 @@ export class MediaService extends BaseService {
|
||||
return extractedSize >= targetSize;
|
||||
}
|
||||
|
||||
private async getDevices() {
|
||||
try {
|
||||
return await this.storageRepository.readdir('/dev/dri');
|
||||
} catch {
|
||||
this.logger.debug('No devices found in /dev/dri.');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async hasMaliOpenCL() {
|
||||
try {
|
||||
const [maliIcdStat, maliDeviceStat] = await Promise.all([
|
||||
this.storageRepository.stat('/etc/OpenCL/vendors/mali.icd'),
|
||||
this.storageRepository.stat('/dev/mali0'),
|
||||
]);
|
||||
return maliIcdStat.isFile() && maliDeviceStat.isCharacterDevice();
|
||||
} catch {
|
||||
this.logger.debug('OpenCL not available for transcoding, so RKMPP acceleration will use CPU tonemapping');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async syncFiles(
|
||||
oldFiles: (AssetFile & { isProgressive: boolean; isTransparent: boolean })[],
|
||||
newFiles: UpsertFileOptions[],
|
||||
|
||||
@@ -672,7 +672,7 @@ describe(MetadataService.name, () => {
|
||||
colorPrimaries: 9,
|
||||
colorTransfer: 16,
|
||||
colorMatrix: 9,
|
||||
dvProfile: undefined,
|
||||
dvProfile: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -41,6 +41,7 @@ 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,6 +269,7 @@ export class QueueService extends BaseService {
|
||||
{ name: JobName.PersonCleanup },
|
||||
{ name: JobName.MemoryCleanup },
|
||||
{ name: JobName.SessionCleanup },
|
||||
{ name: JobName.HlsSessionCleanup },
|
||||
{ name: JobName.AuditTableCleanup },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -148,6 +148,7 @@ describe(ServerService.name, () => {
|
||||
configFile: false,
|
||||
trash: true,
|
||||
email: false,
|
||||
realtimeTranscoding: false,
|
||||
});
|
||||
expect(mocks.systemMetadata.get).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user