Merge branch 'main' of https://github.com/immich-app/immich into 4382-thumbnail-metadata
This commit is contained in:
commit
beb2a48339
28 changed files with 314 additions and 101 deletions
|
@ -18,6 +18,7 @@ module.exports = {
|
|||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-floating-promises': 'error',
|
||||
'prettier/prettier': 0,
|
||||
},
|
||||
};
|
||||
|
|
|
@ -19,9 +19,9 @@ program
|
|||
)
|
||||
.addOption(new Option('--delete', 'Delete local assets after upload').env('IMMICH_DELETE_ASSETS'))
|
||||
.argument('[paths...]', 'One or more paths to assets to be uploaded')
|
||||
.action((paths, options) => {
|
||||
.action(async (paths, options) => {
|
||||
options.excludePatterns = options.ignore;
|
||||
new Upload().run(paths, options);
|
||||
await new Upload().run(paths, options);
|
||||
});
|
||||
|
||||
program
|
||||
|
@ -37,18 +37,18 @@ program
|
|||
.addOption(new Option('-i, --ignore [paths...]', 'Paths to ignore').env('IMMICH_IGNORE_PATHS').default(false))
|
||||
.addOption(new Option('--no-read-only', 'Import files without read-only protection, allowing Immich to manage them'))
|
||||
.argument('[paths...]', 'One or more paths to assets to be imported')
|
||||
.action((paths, options) => {
|
||||
.action(async (paths, options) => {
|
||||
options.import = true;
|
||||
options.excludePatterns = options.ignore;
|
||||
new Upload().run(paths, options);
|
||||
await new Upload().run(paths, options);
|
||||
});
|
||||
|
||||
program
|
||||
.command('server-info')
|
||||
.description('Display server information')
|
||||
|
||||
.action(() => {
|
||||
new ServerInfo().run();
|
||||
.action(async () => {
|
||||
await new ServerInfo().run();
|
||||
});
|
||||
|
||||
program
|
||||
|
@ -56,8 +56,8 @@ program
|
|||
.description('Login using an API key')
|
||||
.argument('[instanceUrl]')
|
||||
.argument('[apiKey]')
|
||||
.action((paths, options) => {
|
||||
new LoginKey().run(paths, options);
|
||||
.action(async (paths, options) => {
|
||||
await new LoginKey().run(paths, options);
|
||||
});
|
||||
|
||||
program.parse(process.argv);
|
||||
|
|
|
@ -67,7 +67,7 @@ describe('SessionService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should create auth file when logged in', async () => {
|
||||
it.skip('should create auth file when logged in', async () => {
|
||||
mockfs();
|
||||
|
||||
await sessionService.keyLogin('https://test/api', 'pNussssKSYo5WasdgalvKJ1n9kdvaasdfbluPg');
|
||||
|
|
|
@ -53,7 +53,14 @@ export class SessionService {
|
|||
|
||||
if (!fs.existsSync(this.configDir)) {
|
||||
// Create config folder if it doesn't exist
|
||||
fs.mkdirSync(this.configDir, { recursive: true });
|
||||
const created = await fs.promises.mkdir(this.configDir, { recursive: true });
|
||||
if (!created) {
|
||||
throw new Error(`Failed to create config folder ${this.configDir}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!fs.existsSync(this.configDir)) {
|
||||
console.error('waah');
|
||||
}
|
||||
|
||||
fs.writeFileSync(this.authPath, yaml.stringify({ instanceUrl, apiKey }));
|
||||
|
|
|
@ -1,35 +1,24 @@
|
|||
import { UploadService } from './upload.service';
|
||||
import mockfs from 'mock-fs';
|
||||
import axios from 'axios';
|
||||
import mockAxios from 'jest-mock-axios';
|
||||
import FormData from 'form-data';
|
||||
import { ApiConfiguration } from '../cores/api-configuration';
|
||||
|
||||
jest.mock('axios', () => jest.fn());
|
||||
|
||||
describe('UploadService', () => {
|
||||
let uploadService: UploadService;
|
||||
|
||||
beforeAll(() => {
|
||||
// Write a dummy output before mock-fs to prevent some annoying errors
|
||||
console.log();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
const apiConfiguration = new ApiConfiguration('https://example.com/api', 'key');
|
||||
|
||||
uploadService = new UploadService(apiConfiguration);
|
||||
});
|
||||
|
||||
it('should upload a single file', async () => {
|
||||
it('should call axios', async () => {
|
||||
const data = new FormData();
|
||||
|
||||
uploadService.upload(data);
|
||||
await uploadService.upload(data);
|
||||
|
||||
mockAxios.mockResponse();
|
||||
expect(axios).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockfs.restore();
|
||||
mockAxios.reset();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -42,21 +42,21 @@ export class UploadService {
|
|||
};
|
||||
}
|
||||
|
||||
public checkIfAssetAlreadyExists(path: string, checksum: string): Promise<any> {
|
||||
public checkIfAssetAlreadyExists(path: string, checksum: string) {
|
||||
this.checkAssetExistenceConfig.data = JSON.stringify({ assets: [{ id: path, checksum: checksum }] });
|
||||
|
||||
// TODO: retry on 500 errors?
|
||||
return axios(this.checkAssetExistenceConfig);
|
||||
}
|
||||
|
||||
public upload(data: FormData): Promise<any> {
|
||||
public upload(data: FormData) {
|
||||
this.uploadConfig.data = data;
|
||||
|
||||
// TODO: retry on 500 errors?
|
||||
return axios(this.uploadConfig);
|
||||
}
|
||||
|
||||
public import(data: any): Promise<any> {
|
||||
public import(data: any) {
|
||||
this.importConfig.data = data;
|
||||
|
||||
// TODO: retry on 500 errors?
|
||||
|
|
|
@ -173,6 +173,8 @@
|
|||
"library_page_sharing": "Sharing",
|
||||
"library_page_sort_created": "Most recently created",
|
||||
"library_page_sort_title": "Album title",
|
||||
"library_page_sort_most_recent_photo": "Most recent photo",
|
||||
"library_page_sort_last_modified": "Last modified",
|
||||
"login_disabled": "Login has been disabled",
|
||||
"login_form_api_exception": "API exception. Please check the server URL and try again.",
|
||||
"login_form_handshake_exception": "There was an Handshake Exception with the server. Enable self-signed certificate support in the settings if you are using a self-signed certificate.",
|
||||
|
|
|
@ -169,4 +169,4 @@ SPEC CHECKSUMS:
|
|||
|
||||
PODFILE CHECKSUM: 599d8aeb73728400c15364e734525722250a5382
|
||||
|
||||
COCOAPODS: 1.11.3
|
||||
COCOAPODS: 1.12.1
|
||||
|
|
|
@ -47,6 +47,7 @@ class LibraryPage extends HookConsumerWidget {
|
|||
useState(settings.getSetting(AppSettingsEnum.selectedAlbumSortOrder));
|
||||
|
||||
List<Album> sortedAlbums() {
|
||||
// Created.
|
||||
if (selectedAlbumSortOrder.value == 0) {
|
||||
return albums
|
||||
.where((a) => a.isRemote)
|
||||
|
@ -54,6 +55,34 @@ class LibraryPage extends HookConsumerWidget {
|
|||
.reversed
|
||||
.toList();
|
||||
}
|
||||
// Album title.
|
||||
if (selectedAlbumSortOrder.value == 1) {
|
||||
return albums.where((a) => a.isRemote).sortedBy((album) => album.name);
|
||||
}
|
||||
// Most recent photo, if unset (e.g. empty album, use modifiedAt / updatedAt).
|
||||
if (selectedAlbumSortOrder.value == 2) {
|
||||
return albums
|
||||
.where((a) => a.isRemote)
|
||||
.sorted(
|
||||
(a, b) => a.lastModifiedAssetTimestamp != null &&
|
||||
b.lastModifiedAssetTimestamp != null
|
||||
? a.lastModifiedAssetTimestamp!
|
||||
.compareTo(b.lastModifiedAssetTimestamp!)
|
||||
: a.modifiedAt.compareTo(b.modifiedAt),
|
||||
)
|
||||
.reversed
|
||||
.toList();
|
||||
}
|
||||
// Last modified.
|
||||
if (selectedAlbumSortOrder.value == 3) {
|
||||
return albums
|
||||
.where((a) => a.isRemote)
|
||||
.sortedBy((album) => album.modifiedAt)
|
||||
.reversed
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Fallback: Album title.
|
||||
return albums.where((a) => a.isRemote).sortedBy((album) => album.name);
|
||||
}
|
||||
|
||||
|
@ -61,6 +90,8 @@ class LibraryPage extends HookConsumerWidget {
|
|||
final options = [
|
||||
"library_page_sort_created".tr(),
|
||||
"library_page_sort_title".tr(),
|
||||
"library_page_sort_most_recent_photo".tr(),
|
||||
"library_page_sort_last_modified".tr(),
|
||||
];
|
||||
|
||||
return PopupMenuButton(
|
||||
|
|
|
@ -364,7 +364,18 @@ class ExifBottomSheet extends HookConsumerWidget {
|
|||
children: [
|
||||
buildDragHeader(),
|
||||
buildDate(),
|
||||
if (asset.isRemote) DescriptionInput(asset: asset),
|
||||
assetWithExif.when(
|
||||
data: (data) => DescriptionInput(asset: data),
|
||||
error: (error, stackTrace) => Icon(
|
||||
Icons.image_not_supported_outlined,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
loading: () => const SizedBox(
|
||||
width: 75,
|
||||
height: 75,
|
||||
child: CircularProgressIndicator.adaptive(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8.0),
|
||||
buildLocation(),
|
||||
SizedBox(height: hasCoordinates(exifInfo) ? 16.0 : 0.0),
|
||||
|
|
|
@ -2,6 +2,7 @@ import 'package:auto_route/auto_route.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||
|
||||
class TopControlAppBar extends HookConsumerWidget {
|
||||
const TopControlAppBar({
|
||||
|
@ -14,7 +15,6 @@ class TopControlAppBar extends HookConsumerWidget {
|
|||
required this.isPlayingMotionVideo,
|
||||
required this.onFavorite,
|
||||
required this.onUploadPressed,
|
||||
required this.isFavorite,
|
||||
}) : super(key: key);
|
||||
|
||||
final Asset asset;
|
||||
|
@ -23,19 +23,19 @@ class TopControlAppBar extends HookConsumerWidget {
|
|||
final VoidCallback? onDownloadPressed;
|
||||
final VoidCallback onToggleMotionVideo;
|
||||
final VoidCallback onAddToAlbumPressed;
|
||||
final VoidCallback? onFavorite;
|
||||
final Function(Asset) onFavorite;
|
||||
final bool isPlayingMotionVideo;
|
||||
final bool isFavorite;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
const double iconSize = 22.0;
|
||||
final a = ref.watch(assetWatcher(asset)).value ?? asset;
|
||||
|
||||
Widget buildFavoriteButton() {
|
||||
Widget buildFavoriteButton(a) {
|
||||
return IconButton(
|
||||
onPressed: onFavorite,
|
||||
onPressed: () => onFavorite(a),
|
||||
icon: Icon(
|
||||
isFavorite ? Icons.favorite : Icons.favorite_border,
|
||||
a.isFavorite ? Icons.favorite : Icons.favorite_border,
|
||||
color: Colors.grey[200],
|
||||
),
|
||||
);
|
||||
|
@ -123,7 +123,7 @@ class TopControlAppBar extends HookConsumerWidget {
|
|||
size: iconSize,
|
||||
),
|
||||
actions: [
|
||||
if (asset.isRemote) buildFavoriteButton(),
|
||||
if (asset.isRemote) buildFavoriteButton(a),
|
||||
if (asset.livePhotoVideoId != null) buildLivePhotoButton(),
|
||||
if (asset.isLocal && !asset.isRemote) buildUploadButton(),
|
||||
if (asset.isRemote && !asset.isLocal) buildDownloadButton(),
|
||||
|
|
|
@ -297,10 +297,8 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
child: TopControlAppBar(
|
||||
isPlayingMotionVideo: isPlayingMotionVideo.value,
|
||||
asset: asset(),
|
||||
isFavorite: asset().isFavorite,
|
||||
onMoreInfoPressed: showInfo,
|
||||
onFavorite:
|
||||
asset().isRemote ? () => toggleFavorite(asset()) : null,
|
||||
onFavorite: toggleFavorite,
|
||||
onUploadPressed:
|
||||
asset().isLocal ? () => handleUpload(asset()) : null,
|
||||
onDownloadPressed: asset().isLocal
|
||||
|
|
|
@ -60,6 +60,39 @@ class ThumbnailImage extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
Widget buildVideoIcon() {
|
||||
final minutes = asset.duration.inMinutes;
|
||||
final durationString = asset.duration.toString();
|
||||
return Positioned(
|
||||
top: 5,
|
||||
right: 5,
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
minutes > 59
|
||||
? durationString.substring(0, 7) // h:mm:ss
|
||||
: minutes > 0
|
||||
? durationString.substring(2, 7) // mm:ss
|
||||
: durationString.substring(3, 7), // m:ss
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
width: 3,
|
||||
),
|
||||
const Icon(
|
||||
Icons.play_circle_fill_rounded,
|
||||
color: Colors.white,
|
||||
size: 18,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildImage() {
|
||||
final image = SizedBox(
|
||||
width: 300,
|
||||
|
@ -162,26 +195,7 @@ class ThumbnailImage extends StatelessWidget {
|
|||
size: 18,
|
||||
),
|
||||
),
|
||||
if (!asset.isImage)
|
||||
Positioned(
|
||||
top: 5,
|
||||
right: 5,
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
asset.duration.toString().substring(0, 7),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.play_circle_outline_rounded,
|
||||
color: Colors.white,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!asset.isImage) buildVideoIcon(),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
@ -18,6 +18,7 @@ class Album {
|
|||
required this.name,
|
||||
required this.createdAt,
|
||||
required this.modifiedAt,
|
||||
this.lastModifiedAssetTimestamp,
|
||||
required this.shared,
|
||||
});
|
||||
|
||||
|
@ -29,6 +30,7 @@ class Album {
|
|||
String name;
|
||||
DateTime createdAt;
|
||||
DateTime modifiedAt;
|
||||
DateTime? lastModifiedAssetTimestamp;
|
||||
bool shared;
|
||||
final IsarLink<User> owner = IsarLink<User>();
|
||||
final IsarLink<Asset> thumbnail = IsarLink<Asset>();
|
||||
|
@ -83,12 +85,21 @@ class Album {
|
|||
@override
|
||||
bool operator ==(other) {
|
||||
if (other is! Album) return false;
|
||||
|
||||
final lastModifiedAssetTimestampIsSetAndEqual =
|
||||
lastModifiedAssetTimestamp != null &&
|
||||
other.lastModifiedAssetTimestamp != null
|
||||
? lastModifiedAssetTimestamp!
|
||||
.isAtSameMomentAs(other.lastModifiedAssetTimestamp!)
|
||||
: true;
|
||||
|
||||
return id == other.id &&
|
||||
remoteId == other.remoteId &&
|
||||
localId == other.localId &&
|
||||
name == other.name &&
|
||||
createdAt.isAtSameMomentAs(other.createdAt) &&
|
||||
modifiedAt.isAtSameMomentAs(other.modifiedAt) &&
|
||||
lastModifiedAssetTimestampIsSetAndEqual &&
|
||||
shared == other.shared &&
|
||||
owner.value == other.owner.value &&
|
||||
thumbnail.value == other.thumbnail.value &&
|
||||
|
@ -105,6 +116,7 @@ class Album {
|
|||
name.hashCode ^
|
||||
createdAt.hashCode ^
|
||||
modifiedAt.hashCode ^
|
||||
lastModifiedAssetTimestamp.hashCode ^
|
||||
shared.hashCode ^
|
||||
owner.value.hashCode ^
|
||||
thumbnail.value.hashCode ^
|
||||
|
@ -130,6 +142,7 @@ class Album {
|
|||
name: dto.albumName,
|
||||
createdAt: dto.createdAt,
|
||||
modifiedAt: dto.updatedAt,
|
||||
lastModifiedAssetTimestamp: dto.lastModifiedAssetTimestamp,
|
||||
shared: dto.shared,
|
||||
);
|
||||
a.owner.value = await db.users.getById(dto.ownerId);
|
||||
|
|
|
@ -22,28 +22,33 @@ const AlbumSchema = CollectionSchema(
|
|||
name: r'createdAt',
|
||||
type: IsarType.dateTime,
|
||||
),
|
||||
r'localId': PropertySchema(
|
||||
r'lastModifiedAssetTimestamp': PropertySchema(
|
||||
id: 1,
|
||||
name: r'lastModifiedAssetTimestamp',
|
||||
type: IsarType.dateTime,
|
||||
),
|
||||
r'localId': PropertySchema(
|
||||
id: 2,
|
||||
name: r'localId',
|
||||
type: IsarType.string,
|
||||
),
|
||||
r'modifiedAt': PropertySchema(
|
||||
id: 2,
|
||||
id: 3,
|
||||
name: r'modifiedAt',
|
||||
type: IsarType.dateTime,
|
||||
),
|
||||
r'name': PropertySchema(
|
||||
id: 3,
|
||||
id: 4,
|
||||
name: r'name',
|
||||
type: IsarType.string,
|
||||
),
|
||||
r'remoteId': PropertySchema(
|
||||
id: 4,
|
||||
id: 5,
|
||||
name: r'remoteId',
|
||||
type: IsarType.string,
|
||||
),
|
||||
r'shared': PropertySchema(
|
||||
id: 5,
|
||||
id: 6,
|
||||
name: r'shared',
|
||||
type: IsarType.bool,
|
||||
)
|
||||
|
@ -143,11 +148,12 @@ void _albumSerialize(
|
|||
Map<Type, List<int>> allOffsets,
|
||||
) {
|
||||
writer.writeDateTime(offsets[0], object.createdAt);
|
||||
writer.writeString(offsets[1], object.localId);
|
||||
writer.writeDateTime(offsets[2], object.modifiedAt);
|
||||
writer.writeString(offsets[3], object.name);
|
||||
writer.writeString(offsets[4], object.remoteId);
|
||||
writer.writeBool(offsets[5], object.shared);
|
||||
writer.writeDateTime(offsets[1], object.lastModifiedAssetTimestamp);
|
||||
writer.writeString(offsets[2], object.localId);
|
||||
writer.writeDateTime(offsets[3], object.modifiedAt);
|
||||
writer.writeString(offsets[4], object.name);
|
||||
writer.writeString(offsets[5], object.remoteId);
|
||||
writer.writeBool(offsets[6], object.shared);
|
||||
}
|
||||
|
||||
Album _albumDeserialize(
|
||||
|
@ -158,11 +164,12 @@ Album _albumDeserialize(
|
|||
) {
|
||||
final object = Album(
|
||||
createdAt: reader.readDateTime(offsets[0]),
|
||||
localId: reader.readStringOrNull(offsets[1]),
|
||||
modifiedAt: reader.readDateTime(offsets[2]),
|
||||
name: reader.readString(offsets[3]),
|
||||
remoteId: reader.readStringOrNull(offsets[4]),
|
||||
shared: reader.readBool(offsets[5]),
|
||||
lastModifiedAssetTimestamp: reader.readDateTimeOrNull(offsets[1]),
|
||||
localId: reader.readStringOrNull(offsets[2]),
|
||||
modifiedAt: reader.readDateTime(offsets[3]),
|
||||
name: reader.readString(offsets[4]),
|
||||
remoteId: reader.readStringOrNull(offsets[5]),
|
||||
shared: reader.readBool(offsets[6]),
|
||||
);
|
||||
object.id = id;
|
||||
return object;
|
||||
|
@ -178,14 +185,16 @@ P _albumDeserializeProp<P>(
|
|||
case 0:
|
||||
return (reader.readDateTime(offset)) as P;
|
||||
case 1:
|
||||
return (reader.readStringOrNull(offset)) as P;
|
||||
return (reader.readDateTimeOrNull(offset)) as P;
|
||||
case 2:
|
||||
return (reader.readDateTime(offset)) as P;
|
||||
case 3:
|
||||
return (reader.readString(offset)) as P;
|
||||
case 4:
|
||||
return (reader.readStringOrNull(offset)) as P;
|
||||
case 3:
|
||||
return (reader.readDateTime(offset)) as P;
|
||||
case 4:
|
||||
return (reader.readString(offset)) as P;
|
||||
case 5:
|
||||
return (reader.readStringOrNull(offset)) as P;
|
||||
case 6:
|
||||
return (reader.readBool(offset)) as P;
|
||||
default:
|
||||
throw IsarError('Unknown property with id $propertyId');
|
||||
|
@ -520,6 +529,80 @@ extension AlbumQueryFilter on QueryBuilder<Album, Album, QFilterCondition> {
|
|||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Album, Album, QAfterFilterCondition>
|
||||
lastModifiedAssetTimestampIsNull() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(const FilterCondition.isNull(
|
||||
property: r'lastModifiedAssetTimestamp',
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Album, Album, QAfterFilterCondition>
|
||||
lastModifiedAssetTimestampIsNotNull() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(const FilterCondition.isNotNull(
|
||||
property: r'lastModifiedAssetTimestamp',
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Album, Album, QAfterFilterCondition>
|
||||
lastModifiedAssetTimestampEqualTo(DateTime? value) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.equalTo(
|
||||
property: r'lastModifiedAssetTimestamp',
|
||||
value: value,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Album, Album, QAfterFilterCondition>
|
||||
lastModifiedAssetTimestampGreaterThan(
|
||||
DateTime? value, {
|
||||
bool include = false,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.greaterThan(
|
||||
include: include,
|
||||
property: r'lastModifiedAssetTimestamp',
|
||||
value: value,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Album, Album, QAfterFilterCondition>
|
||||
lastModifiedAssetTimestampLessThan(
|
||||
DateTime? value, {
|
||||
bool include = false,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.lessThan(
|
||||
include: include,
|
||||
property: r'lastModifiedAssetTimestamp',
|
||||
value: value,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Album, Album, QAfterFilterCondition>
|
||||
lastModifiedAssetTimestampBetween(
|
||||
DateTime? lower,
|
||||
DateTime? upper, {
|
||||
bool includeLower = true,
|
||||
bool includeUpper = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.between(
|
||||
property: r'lastModifiedAssetTimestamp',
|
||||
lower: lower,
|
||||
includeLower: includeLower,
|
||||
upper: upper,
|
||||
includeUpper: includeUpper,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Album, Album, QAfterFilterCondition> localIdIsNull() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(const FilterCondition.isNull(
|
||||
|
@ -1158,6 +1241,19 @@ extension AlbumQuerySortBy on QueryBuilder<Album, Album, QSortBy> {
|
|||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Album, Album, QAfterSortBy> sortByLastModifiedAssetTimestamp() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'lastModifiedAssetTimestamp', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Album, Album, QAfterSortBy>
|
||||
sortByLastModifiedAssetTimestampDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'lastModifiedAssetTimestamp', Sort.desc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Album, Album, QAfterSortBy> sortByLocalId() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'localId', Sort.asc);
|
||||
|
@ -1244,6 +1340,19 @@ extension AlbumQuerySortThenBy on QueryBuilder<Album, Album, QSortThenBy> {
|
|||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Album, Album, QAfterSortBy> thenByLastModifiedAssetTimestamp() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'lastModifiedAssetTimestamp', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Album, Album, QAfterSortBy>
|
||||
thenByLastModifiedAssetTimestampDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'lastModifiedAssetTimestamp', Sort.desc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Album, Album, QAfterSortBy> thenByLocalId() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'localId', Sort.asc);
|
||||
|
@ -1312,6 +1421,12 @@ extension AlbumQueryWhereDistinct on QueryBuilder<Album, Album, QDistinct> {
|
|||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Album, Album, QDistinct> distinctByLastModifiedAssetTimestamp() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addDistinctBy(r'lastModifiedAssetTimestamp');
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Album, Album, QDistinct> distinctByLocalId(
|
||||
{bool caseSensitive = true}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
|
@ -1359,6 +1474,13 @@ extension AlbumQueryProperty on QueryBuilder<Album, Album, QQueryProperty> {
|
|||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Album, DateTime?, QQueryOperations>
|
||||
lastModifiedAssetTimestampProperty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addPropertyName(r'lastModifiedAssetTimestamp');
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Album, String?, QQueryOperations> localIdProperty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addPropertyName(r'localId');
|
||||
|
|
|
@ -200,6 +200,12 @@ final assetDetailProvider =
|
|||
}
|
||||
});
|
||||
|
||||
final assetWatcher =
|
||||
StreamProvider.autoDispose.family<Asset?, Asset>((ref, asset) {
|
||||
final db = ref.watch(dbProvider);
|
||||
return db.assets.watchObject(asset.id, fireImmediately: true);
|
||||
});
|
||||
|
||||
final assetsProvider =
|
||||
StreamProvider.family<RenderList, int?>((ref, userId) async* {
|
||||
if (userId == null) return;
|
||||
|
|
|
@ -282,6 +282,9 @@ class SyncService {
|
|||
if (!_hasAlbumResponseDtoChanged(dto, album)) {
|
||||
return false;
|
||||
}
|
||||
// loadDetails (/api/album/:id) will not include lastModifiedAssetTimestamp,
|
||||
// i.e. it will always be null. Save it here.
|
||||
final originalDto = dto;
|
||||
dto = await loadDetails(dto);
|
||||
if (dto.assetCount != dto.assets.length) {
|
||||
return false;
|
||||
|
@ -321,6 +324,7 @@ class SyncService {
|
|||
album.name = dto.albumName;
|
||||
album.shared = dto.shared;
|
||||
album.modifiedAt = dto.updatedAt;
|
||||
album.lastModifiedAssetTimestamp = originalDto.lastModifiedAssetTimestamp;
|
||||
if (album.thumbnail.value?.remoteId != dto.albumThumbnailAssetId) {
|
||||
album.thumbnail.value = await _db.assets
|
||||
.where()
|
||||
|
@ -808,5 +812,13 @@ bool _hasAlbumResponseDtoChanged(AlbumResponseDto dto, Album a) {
|
|||
dto.albumThumbnailAssetId != a.thumbnail.value?.remoteId ||
|
||||
dto.shared != a.shared ||
|
||||
dto.sharedUsers.length != a.sharedUsers.length ||
|
||||
!dto.updatedAt.isAtSameMomentAs(a.modifiedAt);
|
||||
!dto.updatedAt.isAtSameMomentAs(a.modifiedAt) ||
|
||||
(dto.lastModifiedAssetTimestamp == null &&
|
||||
a.lastModifiedAssetTimestamp != null) ||
|
||||
(dto.lastModifiedAssetTimestamp != null &&
|
||||
a.lastModifiedAssetTimestamp == null) ||
|
||||
(dto.lastModifiedAssetTimestamp != null &&
|
||||
a.lastModifiedAssetTimestamp != null &&
|
||||
!dto.lastModifiedAssetTimestamp!
|
||||
.isAtSameMomentAs(a.lastModifiedAssetTimestamp!));
|
||||
}
|
||||
|
|
|
@ -18,6 +18,7 @@ module.exports = {
|
|||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-floating-promises': 'error',
|
||||
'prettier/prettier': 0,
|
||||
},
|
||||
};
|
||||
|
|
|
@ -272,7 +272,7 @@ export class AssetService {
|
|||
zip.addFile(originalPath, filename);
|
||||
}
|
||||
|
||||
zip.finalize();
|
||||
void zip.finalize();
|
||||
|
||||
return { stream: zip.stream };
|
||||
}
|
||||
|
|
|
@ -267,9 +267,9 @@ describe(SearchService.name, () => {
|
|||
});
|
||||
|
||||
describe('handleIndexAlbums', () => {
|
||||
it('should skip if search is disabled', () => {
|
||||
it('should skip if search is disabled', async () => {
|
||||
sut['enabled'] = false;
|
||||
sut.handleIndexAlbums();
|
||||
await sut.handleIndexAlbums();
|
||||
});
|
||||
|
||||
it('should index all the albums', async () => {
|
||||
|
@ -355,18 +355,18 @@ describe(SearchService.name, () => {
|
|||
});
|
||||
|
||||
describe('handleIndexAsset', () => {
|
||||
it('should skip if search is disabled', () => {
|
||||
it('should skip if search is disabled', async () => {
|
||||
sut['enabled'] = false;
|
||||
sut.handleIndexFace({ assetId: 'asset-1', personId: 'person-1' });
|
||||
await sut.handleIndexFace({ assetId: 'asset-1', personId: 'person-1' });
|
||||
|
||||
expect(searchMock.importFaces).not.toHaveBeenCalled();
|
||||
expect(personMock.getFacesByIds).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should index the face', () => {
|
||||
it('should index the face', async () => {
|
||||
personMock.getFacesByIds.mockResolvedValue([faceStub.face1]);
|
||||
|
||||
sut.handleIndexFace({ assetId: 'asset-1', personId: 'person-1' });
|
||||
await sut.handleIndexFace({ assetId: 'asset-1', personId: 'person-1' });
|
||||
|
||||
expect(personMock.getFacesByIds).toHaveBeenCalledWith([{ assetId: 'asset-1', personId: 'person-1' }]);
|
||||
});
|
||||
|
|
|
@ -75,7 +75,7 @@ export class AppModule implements OnModuleInit, OnModuleDestroy {
|
|||
await this.appService.init();
|
||||
}
|
||||
|
||||
onModuleDestroy() {
|
||||
this.appService.destroy();
|
||||
async onModuleDestroy() {
|
||||
await this.appService.destroy();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,7 +16,7 @@ export class CommunicationRepository implements OnGatewayConnection, OnGatewayDi
|
|||
this.logger.log(`New websocket connection: ${client.id}`);
|
||||
const user = await this.authService.validate(client.request.headers, {});
|
||||
if (user) {
|
||||
client.join(user.id);
|
||||
await client.join(user.id);
|
||||
this.send(CommunicationEvent.SERVER_VERSION, user.id, serverVersion);
|
||||
} else {
|
||||
client.emit('error', 'unauthorized');
|
||||
|
@ -28,8 +28,8 @@ export class CommunicationRepository implements OnGatewayConnection, OnGatewayDi
|
|||
}
|
||||
}
|
||||
|
||||
handleDisconnect(client: Socket) {
|
||||
client.leave(client.nsp.name);
|
||||
async handleDisconnect(client: Socket) {
|
||||
await client.leave(client.nsp.name);
|
||||
this.logger.log(`Client ${client.id} disconnected from Websocket`);
|
||||
}
|
||||
|
||||
|
|
|
@ -51,7 +51,7 @@ export class PersonRepository implements IPersonRepository {
|
|||
}
|
||||
|
||||
getAllFaces(): Promise<AssetFaceEntity[]> {
|
||||
return this.assetFaceRepository.find({ relations: { asset: true } });
|
||||
return this.assetFaceRepository.find({ relations: { asset: true }, withDeleted: true });
|
||||
}
|
||||
|
||||
getAll(): Promise<PersonEntity[]> {
|
||||
|
@ -88,6 +88,7 @@ export class PersonRepository implements IPersonRepository {
|
|||
.leftJoin('person.faces', 'face')
|
||||
.having('COUNT(face.assetId) = 0')
|
||||
.groupBy('person.id')
|
||||
.withDeleted()
|
||||
.getMany();
|
||||
}
|
||||
|
||||
|
@ -142,7 +143,7 @@ export class PersonRepository implements IPersonRepository {
|
|||
}
|
||||
|
||||
async getFacesByIds(ids: AssetFaceId[]): Promise<AssetFaceEntity[]> {
|
||||
return this.assetFaceRepository.find({ where: ids, relations: { asset: true } });
|
||||
return this.assetFaceRepository.find({ where: ids, relations: { asset: true }, withDeleted: true });
|
||||
}
|
||||
|
||||
async getRandomFace(personId: string): Promise<AssetFaceEntity | null> {
|
||||
|
|
|
@ -24,4 +24,4 @@ function bootstrap() {
|
|||
process.exit(1);
|
||||
}
|
||||
}
|
||||
bootstrap();
|
||||
void bootstrap();
|
||||
|
|
|
@ -90,14 +90,14 @@ export class AppService {
|
|||
[JobName.LIBRARY_QUEUE_CLEANUP]: () => this.libraryService.handleQueueCleanup(),
|
||||
});
|
||||
|
||||
process.on('uncaughtException', (error: Error | any) => {
|
||||
process.on('uncaughtException', async (error: Error | any) => {
|
||||
const isCsvError = error.code === 'CSV_RECORD_INCONSISTENT_FIELDS_LENGTH';
|
||||
if (!isCsvError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.logger.warn('Geocoding csv parse error, trying again without cache...');
|
||||
this.metadataService.init(true);
|
||||
await this.metadataService.init(true);
|
||||
});
|
||||
|
||||
await this.metadataService.init();
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
import MenuOption from '../shared-components/context-menu/menu-option.svelte';
|
||||
import Portal from '../shared-components/portal/portal.svelte';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
|
||||
export let person: PersonResponseDto;
|
||||
|
||||
|
@ -42,7 +43,7 @@
|
|||
on:mouseleave={() => (showVerticalDots = false)}
|
||||
role="group"
|
||||
>
|
||||
<a href="/people/{person.id}" draggable="false">
|
||||
<a href="/people/{person.id}?previousRoute={AppRoute.PEOPLE}" draggable="false">
|
||||
<div class="h-48 w-48 rounded-xl brightness-95 filter">
|
||||
<ImageThumbnail
|
||||
shadow
|
||||
|
|
|
@ -245,7 +245,7 @@
|
|||
};
|
||||
|
||||
const handleMergeFaces = (detail: PersonResponseDto) => {
|
||||
goto(`${AppRoute.PEOPLE}/${detail.id}?action=merge`);
|
||||
goto(`${AppRoute.PEOPLE}/${detail.id}?action=merge&previousRoute=${AppRoute.PEOPLE}`);
|
||||
};
|
||||
|
||||
const submitNameChange = async () => {
|
||||
|
|
|
@ -132,6 +132,10 @@
|
|||
|
||||
onMount(() => {
|
||||
const action = $page.url.searchParams.get('action');
|
||||
const getPreviousRoute = $page.url.searchParams.get('previousRoute');
|
||||
if (getPreviousRoute) {
|
||||
previousRoute = getPreviousRoute;
|
||||
}
|
||||
if (action == 'merge') {
|
||||
viewMode = ViewMode.MERGE_FACES;
|
||||
}
|
||||
|
@ -176,7 +180,7 @@
|
|||
type: NotificationType.Info,
|
||||
});
|
||||
|
||||
goto(AppRoute.EXPLORE, { replaceState: true });
|
||||
goto(previousRoute, { replaceState: true });
|
||||
} catch (error) {
|
||||
handleError(error, 'Unable to hide person');
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue