Rewrite metadata service, add support for server side exif

This commit is contained in:
Ming Ming 2024-11-21 01:34:24 +08:00
parent 5ec09d5c4c
commit 0e4ecf4f2b
31 changed files with 865 additions and 871 deletions

View file

@ -253,7 +253,7 @@ class FilesController {
final failures = <FileDescriptor>[];
for (final f in files) {
try {
await UpdateProperty(_c)(
await UpdateProperty(fileRepo: _c.fileRepo2)(
account,
f,
metadata: metadata,

View file

@ -6,7 +6,7 @@ import 'package:nc_photos/controller/pref_controller.dart';
import 'package:nc_photos/db/entity_converter.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/file_util.dart' as file_util;
import 'package:nc_photos/service.dart' as service;
import 'package:nc_photos/service/service.dart' as service;
import 'package:np_codegen/np_codegen.dart';
part 'metadata_controller.g.dart';

View file

@ -635,7 +635,7 @@ class FileCachedDataSource implements FileDataSource {
return state.files;
}
await FileSqliteCacheUpdater(_c)(state.account, state.dir,
await FileSqliteCacheUpdater(_c.npDb)(state.account, state.dir,
remote: state.files);
if (shouldCheckCache) {
// update our local touch token to match the remote one
@ -657,7 +657,7 @@ class FileCachedDataSource implements FileDataSource {
if (remote.isCollection != true) {
// only update regular files
_log.info("[listSingle] Cache single file: ${logFilename(f.path)}");
await FileSqliteCacheUpdater(_c).updateSingle(account, remote);
await FileSqliteCacheUpdater(_c.npDb).updateSingle(account, remote);
}
return remote;
}

View file

@ -8,6 +8,7 @@ import 'package:nc_photos/entity/file/data_source.dart';
import 'package:nc_photos/entity/file_descriptor.dart';
import 'package:nc_photos/exception.dart';
import 'package:np_codegen/np_codegen.dart';
import 'package:np_db/np_db.dart';
part 'file_cache_manager.g.dart';
@ -81,7 +82,7 @@ class FileCacheLoader {
@npLog
class FileSqliteCacheUpdater {
const FileSqliteCacheUpdater(this._c);
const FileSqliteCacheUpdater(this.db);
Future<void> call(
Account account,
@ -90,7 +91,7 @@ class FileSqliteCacheUpdater {
}) async {
final s = Stopwatch()..start();
try {
await _c.npDb.syncDirFiles(
await db.syncDirFiles(
account: account.toDb(),
dirFile: dir.toDbKey(),
files: remote.map((e) => e.toDb()).toList(),
@ -101,13 +102,13 @@ class FileSqliteCacheUpdater {
}
Future<void> updateSingle(Account account, File remoteFile) async {
await _c.npDb.syncFile(
await db.syncFile(
account: account.toDb(),
file: remoteFile.toDb(),
);
}
final DiContainer _c;
final NpDb db;
}
class FileSqliteCacheEmptier {

View file

@ -46,6 +46,7 @@ class AccountPrefUpdatedEvent {
final dynamic value;
}
@Deprecated("not fired anymore, to be removed")
class FilePropertyUpdatedEvent {
FilePropertyUpdatedEvent(this.account, this.file, this.properties);
@ -97,26 +98,6 @@ class FavoriteResyncedEvent {
final Account account;
}
enum MetadataTaskState {
/// No work is being done
idle,
/// Processing images
prcoessing,
/// Paused on data network
waitingForWifi,
/// Paused on low battery
lowBattery,
}
class MetadataTaskStateChangedEvent {
const MetadataTaskStateChangedEvent(this.state);
final MetadataTaskState state;
}
@Deprecated("not fired anymore, to be removed")
class PrefUpdatedEvent {
PrefUpdatedEvent(this.key, this.value);

View file

@ -1,134 +0,0 @@
import 'dart:async';
import 'package:event_bus/event_bus.dart';
import 'package:kiwi/kiwi.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/file_util.dart' as file_util;
import 'package:nc_photos/entity/pref.dart';
import 'package:nc_photos/event/event.dart';
import 'package:nc_photos/use_case/update_missing_metadata.dart';
import 'package:np_codegen/np_codegen.dart';
import 'package:np_geocoder/np_geocoder.dart';
import 'package:to_string/to_string.dart';
part 'metadata_task_manager.g.dart';
/// Task to update metadata for missing files
@npLog
@ToString(ignorePrivate: true)
class MetadataTask {
MetadataTask(this._c, this.account, this.pref) : assert(require(_c));
static bool require(DiContainer c) => DiContainer.has(c, DiType.fileRepo);
@override
String toString() => _$toString();
Future<void> call() async {
try {
final shareFolder =
File(path: file_util.unstripPath(account, pref.getShareFolderOr()));
bool hasScanShareFolder = false;
final geocoder = ReverseGeocoder();
await geocoder.init();
for (final r in account.roots) {
final dir = File(path: file_util.unstripPath(account, r));
hasScanShareFolder |= file_util.isOrUnderDir(shareFolder, dir);
final op = UpdateMissingMetadata(
_c, const _UpdateMissingMetadataConfigProvider(), geocoder);
await for (final _ in op(account, dir)) {
if (!Pref().isEnableExifOr()) {
_log.info("[call] EXIF disabled, task ending immaturely");
op.stop();
return;
}
}
}
if (!hasScanShareFolder) {
final op = UpdateMissingMetadata(
_c, const _UpdateMissingMetadataConfigProvider(), geocoder);
await for (final _ in op(
account,
shareFolder,
isRecursive: false,
filter: (f) => f.ownerId != account.userId,
)) {
if (!Pref().isEnableExifOr()) {
_log.info("[call] EXIF disabled, task ending immaturely");
op.stop();
return;
}
}
}
} finally {
KiwiContainer()
.resolve<EventBus>()
.fire(const MetadataTaskStateChangedEvent(MetadataTaskState.idle));
}
}
final DiContainer _c;
final Account account;
@ignore
final AccountPref pref;
}
/// Manage metadata tasks to run concurrently
@npLog
class MetadataTaskManager {
factory MetadataTaskManager() {
_inst ??= MetadataTaskManager._();
return _inst!;
}
MetadataTaskManager._() {
_stateChangedListener.begin();
_handleStream();
}
/// Add a task to the queue
void addTask(MetadataTask task) {
_log.info("[addTask] New task added: $task");
_streamController.add(task);
}
MetadataTaskState get state => _currentState;
void _onMetadataTaskStateChanged(MetadataTaskStateChangedEvent ev) {
if (ev.state != _currentState) {
_currentState = ev.state;
}
}
Future<void> _handleStream() async {
await for (final task in _streamController.stream) {
if (Pref().isEnableExifOr()) {
_log.info("[_doTask] Executing task: $task");
await task();
} else {
_log.info("[_doTask] Ignoring task: $task");
}
}
}
final _streamController = StreamController<MetadataTask>.broadcast();
var _currentState = MetadataTaskState.idle;
late final _stateChangedListener =
AppEventListener<MetadataTaskStateChangedEvent>(
_onMetadataTaskStateChanged);
static MetadataTaskManager? _inst;
}
class _UpdateMissingMetadataConfigProvider
implements UpdateMissingMetadataConfigProvider {
const _UpdateMissingMetadataConfigProvider();
@override
isWifiOnly() async => Pref().shouldProcessExifWifiOnlyOr();
}

View file

@ -1,32 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'metadata_task_manager.dart';
// **************************************************************************
// NpLogGenerator
// **************************************************************************
extension _$MetadataTaskNpLog on MetadataTask {
// ignore: unused_element
Logger get _log => log;
static final log = Logger("metadata_task_manager.MetadataTask");
}
extension _$MetadataTaskManagerNpLog on MetadataTaskManager {
// ignore: unused_element
Logger get _log => log;
static final log = Logger("metadata_task_manager.MetadataTaskManager");
}
// **************************************************************************
// ToStringGenerator
// **************************************************************************
extension _$MetadataTaskToString on MetadataTask {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "MetadataTask {account: $account}";
}
}

View file

@ -1,358 +0,0 @@
import 'dart:async';
import 'package:devicelocale/devicelocale.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_background_service/flutter_background_service.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_gen/gen_l10n/app_localizations_en.dart';
import 'package:kiwi/kiwi.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/app_init.dart' as app_init;
import 'package:nc_photos/app_localizations.dart';
import 'package:nc_photos/controller/pref_controller.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/file/data_source.dart';
import 'package:nc_photos/entity/file_descriptor.dart';
import 'package:nc_photos/entity/file_util.dart' as file_util;
import 'package:nc_photos/entity/pref.dart';
import 'package:nc_photos/event/event.dart';
import 'package:nc_photos/event/native_event.dart';
import 'package:nc_photos/language_util.dart' as language_util;
import 'package:nc_photos/use_case/update_missing_metadata.dart';
import 'package:nc_photos_plugin/nc_photos_plugin.dart';
import 'package:np_async/np_async.dart';
import 'package:np_codegen/np_codegen.dart';
import 'package:np_geocoder/np_geocoder.dart';
import 'package:np_platform_message_relay/np_platform_message_relay.dart';
part 'service.g.dart';
/// Start the background service
Future<void> startService() async {
_C.log.info("[startService] Starting service");
final service = FlutterBackgroundService();
await service.configure(
androidConfiguration: AndroidConfiguration(
onStart: serviceMain,
autoStart: false,
isForegroundMode: true,
foregroundServiceNotificationTitle:
L10n.global().metadataTaskProcessingNotification,
),
iosConfiguration: IosConfiguration(
onForeground: () => throw UnimplementedError(),
onBackground: () => throw UnimplementedError(),
),
);
// sync settings
await ServiceConfig.setProcessExifWifiOnly(
Pref().shouldProcessExifWifiOnlyOr());
await service.start();
}
/// Ask the background service to stop ASAP
void stopService() {
_C.log.info("[stopService] Stopping service");
FlutterBackgroundService().sendData({
_dataKeyEvent: _eventStop,
});
}
@visibleForTesting
@pragma("vm:entry-point")
Future<void> serviceMain() async {
_Service._shouldRun.value = true;
WidgetsFlutterBinding.ensureInitialized();
await _Service()();
}
class ServiceConfig {
static Future<void> setProcessExifWifiOnly(bool flag) async {
await Preference.setBool(_servicePref, _servicePrefProcessWifiOnly, flag);
}
}
@npLog
class _Service {
Future<void> call() async {
final service = FlutterBackgroundService();
service.setForegroundMode(true);
await app_init.init(app_init.InitIsolateType.flutterIsolate);
await _L10n().init();
_log.info("[call] Service started");
final onCancelSubscription = service.onCancel.listen((_) {
_log.info("[call] User canceled");
_stopSelf();
});
final onDataSubscription =
service.onDataReceived.listen((event) => _onReceiveData(event ?? {}));
try {
await _doWork();
} catch (e, stackTrace) {
_log.shout("[call] Uncaught exception", e, stackTrace);
}
await onCancelSubscription.cancel();
await onDataSubscription.cancel();
await KiwiContainer().resolve<DiContainer>().npDb.dispose();
service.stopBackgroundService();
_log.info("[call] Service stopped");
}
Future<void> _doWork() async {
final prefController = PrefController(Pref());
final account = prefController.currentAccountValue;
if (account == null) {
_log.shout("[_doWork] account == null");
return;
}
final accountPref = AccountPref.of(account);
final service = FlutterBackgroundService();
final metadataTask = _MetadataTask(service, account, accountPref);
_metadataTaskStateChangedListener.begin();
try {
await metadataTask();
} finally {
_metadataTaskStateChangedListener.end();
}
}
void _onReceiveData(Map<String, dynamic> data) {
try {
final event = data[_dataKeyEvent];
switch (event) {
case _eventStop:
_stopSelf();
break;
default:
_log.severe("[_onReceiveData] Unknown event: $event");
break;
}
} catch (e, stackTrace) {
_log.shout("[_onReceiveData] Uncaught exception", e, stackTrace);
}
}
void _onMetadataTaskStateChanged(MetadataTaskStateChangedEvent ev) {
if (ev.state == _metadataTaskState) {
return;
}
_metadataTaskState = ev.state;
if (_isPaused != true) {
if (ev.state == MetadataTaskState.waitingForWifi) {
FlutterBackgroundService()
..setNotificationInfo(
title: _L10n.global().metadataTaskPauseNoWiFiNotification,
)
..pauseWakeLock();
_isPaused = true;
} else if (ev.state == MetadataTaskState.lowBattery) {
FlutterBackgroundService()
..setNotificationInfo(
title: _L10n.global().metadataTaskPauseLowBatteryNotification,
)
..pauseWakeLock();
_isPaused = true;
}
} else {
if (ev.state == MetadataTaskState.prcoessing) {
FlutterBackgroundService().resumeWakeLock();
_isPaused = false;
}
}
}
void _stopSelf() {
_log.info("[_stopSelf] Stopping service");
FlutterBackgroundService().setNotificationInfo(
title: _L10n.global().backgroundServiceStopping,
);
_shouldRun.value = false;
}
var _metadataTaskState = MetadataTaskState.idle;
late final _metadataTaskStateChangedListener =
AppEventListener<MetadataTaskStateChangedEvent>(
_onMetadataTaskStateChanged);
bool? _isPaused;
static final _shouldRun = ValueNotifier<bool>(true);
}
/// Access localized string out of the main isolate
@npLog
class _L10n {
factory _L10n() => _inst;
_L10n._();
Future<void> init() async {
try {
final locale = language_util.getSelectedLocale();
if (locale == null) {
_l10n = await _queryL10n();
} else {
_l10n = lookupAppLocalizations(locale);
}
} catch (e, stackTrace) {
_log.shout("[init] Uncaught exception", e, stackTrace);
_l10n = AppLocalizationsEn();
}
}
static AppLocalizations global() => _L10n()._l10n;
Future<AppLocalizations> _queryL10n() async {
try {
final locale = await Devicelocale.currentAsLocale;
return lookupAppLocalizations(locale!);
} on FlutterError catch (_) {
// unsupported locale, use default (en)
return AppLocalizationsEn();
} catch (e, stackTrace) {
_log.shout(
"[_queryL10n] Failed while lookupAppLocalizations", e, stackTrace);
return AppLocalizationsEn();
}
}
static final _inst = _L10n._();
late AppLocalizations _l10n;
}
@npLog
class _MetadataTask {
_MetadataTask(this.service, this.account, this.accountPref);
Future<void> call() async {
try {
await _updateMetadata();
} catch (e, stackTrace) {
_log.shout("[call] Uncaught exception", e, stackTrace);
}
if (_processedIds.isNotEmpty) {
unawaited(
MessageRelay.broadcast(FileExifUpdatedEvent(_processedIds).toEvent()),
);
_processedIds = [];
}
final c = KiwiContainer().resolve<DiContainer>();
if (c.fileRepo.dataSrc is FileCachedDataSource) {
await (c.fileRepo.dataSrc as FileCachedDataSource).flushRemoteTouch();
}
}
Future<void> _updateMetadata() async {
final shareFolder = File(
path: file_util.unstripPath(account, accountPref.getShareFolderOr()));
bool hasScanShareFolder = false;
final c = KiwiContainer().resolve<DiContainer>();
final geocoder = ReverseGeocoder();
await geocoder.init();
for (final r in account.roots) {
final dir = File(path: file_util.unstripPath(account, r));
hasScanShareFolder |= file_util.isOrUnderDir(shareFolder, dir);
final updater = UpdateMissingMetadata(
c, const _UpdateMissingMetadataConfigProvider(), geocoder);
void onServiceStop() {
_log.info("[_updateMetadata] Stopping task: user canceled");
updater.stop();
_shouldRun = false;
}
_Service._shouldRun.addListener(onServiceStop);
try {
await for (final ev in updater(account, dir)) {
if (ev is File) {
_onFileProcessed(ev);
}
}
} finally {
_Service._shouldRun.removeListener(onServiceStop);
}
if (!_shouldRun) {
return;
}
}
if (!hasScanShareFolder) {
final shareUpdater = UpdateMissingMetadata(
c, const _UpdateMissingMetadataConfigProvider(), geocoder);
void onServiceStop() {
_log.info("[_updateMetadata] Stopping task: user canceled");
shareUpdater.stop();
_shouldRun = false;
}
_Service._shouldRun.addListener(onServiceStop);
try {
await for (final ev in shareUpdater(
account,
shareFolder,
isRecursive: false,
filter: (f) => f.ownerId != account.userId,
)) {
if (ev is File) {
_onFileProcessed(ev);
}
}
} finally {
_Service._shouldRun.removeListener(onServiceStop);
}
if (!_shouldRun) {
return;
}
}
}
void _onFileProcessed(File file) {
++_count;
service.setNotificationInfo(
title: _L10n.global().metadataTaskProcessingNotification,
content: file.strippedPath,
);
_processedIds.add(file.fileId!);
if (_processedIds.length >= 10) {
MessageRelay.broadcast(FileExifUpdatedEvent(_processedIds).toEvent());
_processedIds = [];
}
}
final FlutterBackgroundService service;
final Account account;
final AccountPref accountPref;
var _shouldRun = true;
var _count = 0;
var _processedIds = <int>[];
}
class _UpdateMissingMetadataConfigProvider
implements UpdateMissingMetadataConfigProvider {
const _UpdateMissingMetadataConfigProvider();
@override
isWifiOnly() =>
Preference.getBool(_servicePref, _servicePrefProcessWifiOnly, true)
.notNull();
}
class _C {
// needed to work with generator logger
static final log = Logger("service");
}
const _dataKeyEvent = "event";
const _eventStop = "stop";
const _servicePref = "service";
const _servicePrefProcessWifiOnly = "shouldProcessWifiOnly";

View file

@ -0,0 +1,15 @@
part of 'service.dart';
class ServiceConfig {
static Future<bool> isProcessExifWifiOnly() async {
return Preference.getBool(_pref, _prefProcessWifiOnly, true)
.notNull();
}
static Future<void> setProcessExifWifiOnly(bool flag) async {
await Preference.setBool(_pref, _prefProcessWifiOnly, flag);
}
static const _pref = "service";
static const _prefProcessWifiOnly = "shouldProcessWifiOnly";
}

43
app/lib/service/l10n.dart Normal file
View file

@ -0,0 +1,43 @@
part of 'service.dart';
/// Access localized string out of the main isolate
@npLog
class _L10n {
_L10n._();
factory _L10n() => _inst;
Future<void> init() async {
try {
final locale = language_util.getSelectedLocale();
if (locale == null) {
_l10n = await _queryL10n();
} else {
_l10n = lookupAppLocalizations(locale);
}
} catch (e, stackTrace) {
_log.shout("[init] Uncaught exception", e, stackTrace);
_l10n = AppLocalizationsEn();
}
}
static AppLocalizations global() => _L10n()._l10n;
Future<AppLocalizations> _queryL10n() async {
try {
final locale = await Devicelocale.currentAsLocale;
return lookupAppLocalizations(locale!);
} on FlutterError catch (_) {
// unsupported locale, use default (en)
return AppLocalizationsEn();
} catch (e, stackTrace) {
_log.shout(
"[_queryL10n] Failed while lookupAppLocalizations", e, stackTrace);
return AppLocalizationsEn();
}
}
late AppLocalizations _l10n;
static final _inst = _L10n._();
}

View file

@ -0,0 +1,192 @@
import 'dart:async';
import 'package:devicelocale/devicelocale.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_background_service/flutter_background_service.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_gen/gen_l10n/app_localizations_en.dart';
import 'package:kiwi/kiwi.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/app_init.dart' as app_init;
import 'package:nc_photos/app_localizations.dart';
import 'package:nc_photos/controller/pref_controller.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/file_descriptor.dart';
import 'package:nc_photos/entity/pref.dart';
import 'package:nc_photos/event/native_event.dart';
import 'package:nc_photos/language_util.dart' as language_util;
import 'package:nc_photos/use_case/battery_ensurer.dart';
import 'package:nc_photos/use_case/sync_metadata/sync_metadata.dart';
import 'package:nc_photos/use_case/wifi_ensurer.dart';
import 'package:nc_photos_plugin/nc_photos_plugin.dart';
import 'package:np_async/np_async.dart';
import 'package:np_codegen/np_codegen.dart';
import 'package:np_platform_message_relay/np_platform_message_relay.dart';
part 'config.dart';
part 'l10n.dart';
part 'service.g.dart';
/// Start the background service
Future<void> startService() async {
_$__NpLog.log.info("[startService] Starting service");
final service = FlutterBackgroundService();
await service.configure(
androidConfiguration: AndroidConfiguration(
onStart: serviceMain,
autoStart: false,
isForegroundMode: true,
foregroundServiceNotificationTitle:
L10n.global().metadataTaskProcessingNotification,
),
iosConfiguration: IosConfiguration(
onForeground: () => throw UnimplementedError(),
onBackground: () => throw UnimplementedError(),
),
);
// sync settings
await ServiceConfig.setProcessExifWifiOnly(
Pref().shouldProcessExifWifiOnlyOr());
await service.start();
}
/// Ask the background service to stop ASAP
void stopService() {
_$__NpLog.log.info("[stopService] Stopping service");
FlutterBackgroundService().sendData({
_dataKeyEvent: _eventStop,
});
}
@visibleForTesting
@pragma("vm:entry-point")
Future<void> serviceMain() async {
WidgetsFlutterBinding.ensureInitialized();
await _Service()();
}
@npLog
class _Service {
Future<void> call() async {
final service = FlutterBackgroundService();
service.setForegroundMode(true);
await app_init.init(app_init.InitIsolateType.flutterIsolate);
await _L10n().init();
_log.info("[call] Service started");
final onCancelSubscription = service.onCancel.listen((_) {
_log.info("[call] User canceled");
_stopSelf();
});
final onDataSubscription =
service.onDataReceived.listen((event) => _onNotifAction(event ?? {}));
try {
await _doWork();
} catch (e, stackTrace) {
_log.shout("[call] Uncaught exception", e, stackTrace);
}
await onCancelSubscription.cancel();
await onDataSubscription.cancel();
await KiwiContainer().resolve<DiContainer>().npDb.dispose();
service.stopBackgroundService();
_log.info("[call] Service stopped");
}
Future<void> _doWork() async {
final prefController = PrefController(Pref());
final account = prefController.currentAccountValue;
if (account == null) {
_log.shout("[_doWork] account == null");
return;
}
final c = KiwiContainer().resolve<DiContainer>();
final wifiEnsurer = WifiEnsurer(
interrupter: _shouldRun.stream,
);
wifiEnsurer.isWaiting.listen((event) {
if (event) {
FlutterBackgroundService()
..setNotificationInfo(
title: _L10n.global().metadataTaskPauseNoWiFiNotification,
)
..pauseWakeLock();
} else {
FlutterBackgroundService().resumeWakeLock();
}
});
final batteryEnsurer = BatteryEnsurer(
interrupter: _shouldRun.stream,
);
batteryEnsurer.isWaiting.listen((event) {
if (event) {
FlutterBackgroundService()
..setNotificationInfo(
title: _L10n.global().metadataTaskPauseLowBatteryNotification,
)
..pauseWakeLock();
} else {
FlutterBackgroundService().resumeWakeLock();
}
});
final service = FlutterBackgroundService();
final syncOp = SyncMetadata(
fileRepo: c.fileRepo,
fileRepo2: c.fileRepo2,
fileRepoRemote: c.fileRepoRemote,
db: c.npDb,
interrupter: _shouldRun.stream,
wifiEnsurer: wifiEnsurer,
batteryEnsurer: batteryEnsurer,
);
final processedIds = <int>[];
await for (final f in syncOp.syncAccount(account)) {
processedIds.add(f.fdId);
service.setNotificationInfo(
title: _L10n.global().metadataTaskProcessingNotification,
content: f.strippedPath,
);
}
if (processedIds.isNotEmpty) {
await MessageRelay.broadcast(
FileExifUpdatedEvent(processedIds).toEvent());
}
}
void _onNotifAction(Map<String, dynamic> data) {
try {
final event = data[_dataKeyEvent];
switch (event) {
case _eventStop:
_stopSelf();
break;
default:
_log.severe("[_onNotifAction] Unknown event: $event");
break;
}
} catch (e, stackTrace) {
_log.shout("[_onNotifAction] Uncaught exception", e, stackTrace);
}
}
void _stopSelf() {
_log.info("[_stopSelf] Stopping service");
FlutterBackgroundService().setNotificationInfo(
title: _L10n.global().backgroundServiceStopping,
);
_shouldRun.add(null);
}
final _shouldRun = StreamController<void>.broadcast();
}
@npLog
// ignore: camel_case_types
class __ {}
const _dataKeyEvent = "event";
const _eventStop = "stop";

View file

@ -10,19 +10,19 @@ extension _$_ServiceNpLog on _Service {
// ignore: unused_element
Logger get _log => log;
static final log = Logger("service._Service");
static final log = Logger("service.service._Service");
}
extension _$__NpLog on __ {
// ignore: unused_element
Logger get _log => log;
static final log = Logger("service.service.__");
}
extension _$_L10nNpLog on _L10n {
// ignore: unused_element
Logger get _log => log;
static final log = Logger("service._L10n");
}
extension _$_MetadataTaskNpLog on _MetadataTask {
// ignore: unused_element
Logger get _log => log;
static final log = Logger("service._MetadataTask");
static final log = Logger("service.service._L10n");
}

View file

@ -0,0 +1,35 @@
import 'package:battery_plus/battery_plus.dart';
import 'package:nc_photos/exception.dart';
import 'package:rxdart/rxdart.dart';
class BatteryEnsurer {
BatteryEnsurer({
this.interrupter,
}) {
interrupter?.listen((event) {
_shouldRun = false;
});
}
Future<void> call() async {
while (await Battery().batteryLevel <= 15) {
if (!_shouldRun) {
throw const InterruptedException();
}
if (!_isWaiting.value) {
_isWaiting.add(true);
}
await Future.delayed(const Duration(seconds: 5));
}
if (_isWaiting.value) {
_isWaiting.add(false);
}
}
ValueStream<bool> get isWaiting => _isWaiting.stream;
final Stream<void>? interrupter;
var _shouldRun = true;
final _isWaiting = BehaviorSubject.seeded(false);
}

View file

@ -1,50 +0,0 @@
import 'package:nc_photos/account.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/file_util.dart' as file_util;
import 'package:nc_photos/exception_event.dart';
import 'package:nc_photos/use_case/ls.dart';
import 'package:nc_photos/use_case/scan_dir.dart';
class ScanMissingMetadata {
ScanMissingMetadata(this.fileRepo);
/// List all files that support metadata but yet having one under a dir
///
/// The returned stream would emit either File data or ExceptionEvent
///
/// If [isRecursive] is true, [root] and its sub dirs will be listed,
/// otherwise only [root] will be listed. Default to true
Stream<dynamic> call(
Account account,
File root, {
bool isRecursive = true,
}) async* {
if (isRecursive) {
yield* _doRecursive(account, root);
} else {
yield* _doSingle(account, root);
}
}
Stream<dynamic> _doRecursive(Account account, File root) async* {
final dataStream = ScanDir(fileRepo)(account, root);
await for (final d in dataStream) {
if (d is ExceptionEvent) {
yield d;
continue;
}
for (final f in (d as List<File>).where(file_util.isMissingMetadata)) {
yield f;
}
}
}
Stream<dynamic> _doSingle(Account account, File root) async* {
final files = await Ls(fileRepo)(account, root);
for (final f in files.where(file_util.isMissingMetadata)) {
yield f;
}
}
final FileRepo fileRepo;
}

View file

@ -0,0 +1,120 @@
part of 'sync_metadata.dart';
/// Sync metadata using the client side logic
@npLog
class _SyncByApp {
_SyncByApp({
required this.account,
required this.fileRepo,
required this.fileRepo2,
required this.db,
this.interrupter,
required this.wifiEnsurer,
required this.batteryEnsurer,
}) {
interrupter?.listen((event) {
_shouldRun = false;
});
}
Future<void> init() async {
await _geocoder.init();
}
Stream<File> syncFiles({
required List<int> fileIds,
}) async* {
for (final ids in partition(fileIds, 100)) {
yield* _syncGroup(ids);
}
}
Stream<File> _syncGroup(List<int> fileIds) async* {
final files = await db.getFilesByFileIds(
account: account.toDb(),
fileIds: fileIds,
);
for (final f in files) {
final result = await _syncOne(f);
if (result != null) {
yield result;
}
if (!_shouldRun) {
return;
}
}
}
Future<File?> _syncOne(DbFile file) async {
final f = DbFileConverter.fromDb(
account.userId.toCaseInsensitiveString(),
file,
);
_log.fine("[_syncOne] Syncing ${file.relativePath}");
try {
OrNull<Metadata>? metadataUpdate;
OrNull<ImageLocation>? locationUpdate;
if (f.metadata == null) {
// since we need to download multiple images in their original size,
// we only do it with WiFi
await wifiEnsurer();
await batteryEnsurer();
if (!_shouldRun) {
return null;
}
_log.fine("[_syncOne] Updating metadata for ${f.path}");
final binary = await GetFileBinary(fileRepo)(account, f);
final metadata =
(await LoadMetadata().loadRemote(account, f, binary)).copyWith(
fileEtag: f.etag,
);
metadataUpdate = OrNull(metadata);
}
final lat = (metadataUpdate?.obj ?? f.metadata)?.exif?.gpsLatitudeDeg;
final lng = (metadataUpdate?.obj ?? f.metadata)?.exif?.gpsLongitudeDeg;
try {
ImageLocation? location;
if (lat != null && lng != null) {
_log.fine("[_syncOne] Reverse geocoding for ${f.path}");
final l = await _geocoder(lat, lng);
if (l != null) {
location = l.toImageLocation();
}
}
locationUpdate = OrNull(location ?? ImageLocation.empty());
} catch (e, stackTrace) {
_log.severe("[_syncOne] Failed while reverse geocoding: ${f.path}", e,
stackTrace);
// if failed, we skip updating the location
}
if (metadataUpdate != null || locationUpdate != null) {
await UpdateProperty(fileRepo: fileRepo2)(
account,
f,
metadata: metadataUpdate,
location: locationUpdate,
);
return f;
} else {
return null;
}
} catch (e, stackTrace) {
_log.severe("[_syncOne] Failed while updating metadata: ${f.path}", e,
stackTrace);
return null;
}
}
final Account account;
final FileRepo fileRepo;
final FileRepo2 fileRepo2;
final NpDb db;
final Stream<void>? interrupter;
final WifiEnsurer wifiEnsurer;
final BatteryEnsurer batteryEnsurer;
final _geocoder = ReverseGeocoder();
var _shouldRun = true;
}

View file

@ -0,0 +1,91 @@
part of 'sync_metadata.dart';
/// Sync metadata using the client side logic
@npLog
class _SyncByServer {
_SyncByServer({
required this.account,
required this.fileRepoRemote,
required this.fileRepo2,
required this.db,
this.interrupter,
}) {
interrupter?.listen((event) {
_shouldRun = false;
});
}
Future<void> init() async {
await _geocoder.init();
}
Stream<File> syncFiles({
required List<String> relativePaths,
}) async* {
final dirs = relativePaths.map(dirname).toSet();
for (final dir in dirs) {
yield* _syncDir(
dir: File(path: file_util.unstripPath(account, dir)),
);
}
}
Stream<File> _syncDir({
required File dir,
}) async* {
try {
_log.fine("[_syncDir] Syncing dir $dir");
final files = await fileRepoRemote.list(account, dir);
await FileSqliteCacheUpdater(db)(account, dir, remote: files);
for (final f in files) {
if (f.metadata != null && f.location == null) {
final result = await _syncOne(f);
if (result != null) {
yield result;
}
if (!_shouldRun) {
return;
}
}
}
} catch (e, stackTrace) {
_log.severe("[_syncDir] Failed to sync dir: $dir", e, stackTrace);
}
}
Future<File?> _syncOne(File file) async {
try {
final lat = file.metadata!.exif?.gpsLatitudeDeg;
final lng = file.metadata!.exif?.gpsLongitudeDeg;
ImageLocation? location;
if (lat != null && lng != null) {
_log.fine("[_syncOne] Reverse geocoding for ${file.path}");
final l = await _geocoder(lat, lng);
if (l != null) {
location = l.toImageLocation();
}
}
final locationUpdate = OrNull(location ?? ImageLocation.empty());
await UpdateProperty(fileRepo: fileRepo2)(
account,
file,
metadata: OrNull(file.metadata),
location: locationUpdate,
);
return file;
} catch (e, stackTrace) {
_log.severe("[_syncOne] Failed while updating location: ${file.path}", e,
stackTrace);
return null;
}
}
final Account account;
final FileRepo fileRepoRemote;
final FileRepo2 fileRepo2;
final NpDb db;
final Stream<void>? interrupter;
final _geocoder = ReverseGeocoder();
var _shouldRun = true;
}

View file

@ -0,0 +1,109 @@
import 'dart:async';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/controller/server_controller.dart';
import 'package:nc_photos/db/entity_converter.dart';
import 'package:nc_photos/entity/exif_util.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/file/file_cache_manager.dart';
import 'package:nc_photos/entity/file/repo.dart';
import 'package:nc_photos/entity/file_util.dart' as file_util;
import 'package:nc_photos/geocoder_util.dart';
import 'package:nc_photos/use_case/battery_ensurer.dart';
import 'package:nc_photos/use_case/get_file_binary.dart';
import 'package:nc_photos/use_case/load_metadata.dart';
import 'package:nc_photos/use_case/update_property.dart';
import 'package:nc_photos/use_case/wifi_ensurer.dart';
import 'package:np_codegen/np_codegen.dart';
import 'package:np_collection/np_collection.dart';
import 'package:np_common/or_null.dart';
import 'package:np_db/np_db.dart';
import 'package:np_geocoder/np_geocoder.dart';
import 'package:path/path.dart';
part 'sync_by_app.dart';
part 'sync_by_server.dart';
part 'sync_metadata.g.dart';
@npLog
class SyncMetadata {
const SyncMetadata({
required this.fileRepo,
required this.fileRepo2,
required this.fileRepoRemote,
required this.db,
this.interrupter,
required this.wifiEnsurer,
required this.batteryEnsurer,
});
Stream<File> syncAccount(Account account) async* {
final bool isNcMetadataSupported;
try {
isNcMetadataSupported = await _isNcMetadataSupported(account);
} catch (e) {
_log.severe("[syncAccount] Failed to get server version", e);
return;
}
final files = await db.getFilesByMissingMetadata(
account: account.toDb(),
mimes: file_util.metadataSupportedFormatMimes,
ownerId: account.userId.toCaseInsensitiveString(),
);
_log.info("[syncAccount] Missing count: ${files.items.length}");
if (isNcMetadataSupported) {
yield* _doWithServer(account, files);
} else {
yield* _doWithApp(account, files);
}
}
Stream<File> _doWithApp(
Account account, DbFileMissingMetadataResult files) async* {
final op = _SyncByApp(
account: account,
fileRepo: fileRepo,
fileRepo2: fileRepo2,
db: db,
interrupter: interrupter,
wifiEnsurer: wifiEnsurer,
batteryEnsurer: batteryEnsurer,
);
await op.init();
final stream = op.syncFiles(
fileIds: files.items.map((e) => e.fileId).toList(),
);
yield* stream;
}
Stream<File> _doWithServer(
Account account, DbFileMissingMetadataResult files) async* {
final op = _SyncByServer(
account: account,
fileRepoRemote: fileRepoRemote,
fileRepo2: fileRepo2,
db: db,
interrupter: interrupter,
);
await op.init();
final stream = op.syncFiles(
relativePaths: files.items.map((e) => e.relativePath).toList(),
);
yield* stream;
}
Future<bool> _isNcMetadataSupported(Account account) async {
final serverController = ServerController(account: account);
await serverController.status.first.timeout(const Duration(seconds: 15));
return serverController.isSupported(ServerFeature.ncMetadata);
}
final FileRepo fileRepo;
final FileRepo2 fileRepo2;
final FileRepo fileRepoRemote;
final NpDb db;
final Stream<void>? interrupter;
final WifiEnsurer wifiEnsurer;
final BatteryEnsurer batteryEnsurer;
}

View file

@ -0,0 +1,30 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'sync_metadata.dart';
// **************************************************************************
// NpLogGenerator
// **************************************************************************
extension _$SyncMetadataNpLog on SyncMetadata {
// ignore: unused_element
Logger get _log => log;
static final log =
Logger("use_case.sync_metadata.sync_metadata.SyncMetadata");
}
extension _$_SyncByAppNpLog on _SyncByApp {
// ignore: unused_element
Logger get _log => log;
static final log = Logger("use_case.sync_metadata.sync_metadata._SyncByApp");
}
extension _$_SyncByServerNpLog on _SyncByServer {
// ignore: unused_element
Logger get _log => log;
static final log =
Logger("use_case.sync_metadata.sync_metadata._SyncByServer");
}

View file

@ -1,173 +0,0 @@
import 'package:battery_plus/battery_plus.dart';
import 'package:event_bus/event_bus.dart';
import 'package:kiwi/kiwi.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/connectivity_util.dart' as connectivity_util;
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/exif_util.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/event/event.dart';
import 'package:nc_photos/exception.dart';
import 'package:nc_photos/exception_event.dart';
import 'package:nc_photos/geocoder_util.dart';
import 'package:nc_photos/use_case/get_file_binary.dart';
import 'package:nc_photos/use_case/load_metadata.dart';
import 'package:nc_photos/use_case/scan_missing_metadata.dart';
import 'package:nc_photos/use_case/update_property.dart';
import 'package:np_codegen/np_codegen.dart';
import 'package:np_common/or_null.dart';
import 'package:np_geocoder/np_geocoder.dart';
part 'update_missing_metadata.g.dart';
abstract class UpdateMissingMetadataConfigProvider {
Future<bool> isWifiOnly();
}
@npLog
class UpdateMissingMetadata {
UpdateMissingMetadata(this._c, this.configProvider, this.geocoder);
/// Update metadata for all files that support one under a dir
///
/// The returned stream would emit either File data (for each updated files)
/// or ExceptionEvent
///
/// If [isRecursive] is true, [root] and its sub dirs will be scanned,
/// otherwise only [root] will be scanned. Default to true
///
/// [filter] can be used to filter files -- return true if a file should be
/// included. If [filter] is null, all files will be included.
Stream<dynamic> call(
Account account,
File root, {
bool isRecursive = true,
bool Function(File file)? filter,
}) async* {
final dataStream = ScanMissingMetadata(_c.fileRepo)(
account,
root,
isRecursive: isRecursive,
);
await for (final d in dataStream) {
if (!_shouldRun) {
return;
}
if (d is ExceptionEvent) {
yield d;
continue;
}
final File file = d;
// check if this is a federation share. Nextcloud doesn't support
// properties for such files
if (file.ownerId?.contains("/") == true || filter?.call(d) == false) {
continue;
}
try {
OrNull<Metadata>? metadataUpdate;
OrNull<ImageLocation>? locationUpdate;
if (file.metadata == null) {
// since we need to download multiple images in their original size,
// we only do it with WiFi
await _ensureWifi();
await _ensureBattery();
KiwiContainer().resolve<EventBus>().fire(
const MetadataTaskStateChangedEvent(
MetadataTaskState.prcoessing));
if (!_shouldRun) {
return;
}
_log.fine("[call] Updating metadata for ${file.path}");
final binary = await GetFileBinary(_c.fileRepo)(account, file);
final metadata =
(await LoadMetadata().loadRemote(account, file, binary)).copyWith(
fileEtag: file.etag,
);
metadataUpdate = OrNull(metadata);
} else {
_log.finer("[call] Skip updating metadata for ${file.path}");
KiwiContainer().resolve<EventBus>().fire(
const MetadataTaskStateChangedEvent(
MetadataTaskState.prcoessing));
}
final lat =
(metadataUpdate?.obj ?? file.metadata)?.exif?.gpsLatitudeDeg;
final lng =
(metadataUpdate?.obj ?? file.metadata)?.exif?.gpsLongitudeDeg;
try {
ImageLocation? location;
if (lat != null && lng != null) {
_log.fine("[call] Reverse geocoding for ${file.path}");
final l = await geocoder(lat, lng);
if (l != null) {
location = l.toImageLocation();
}
}
locationUpdate = OrNull(location ?? ImageLocation.empty());
} catch (e, stackTrace) {
_log.severe("[call] Failed while reverse geocoding: ${file.path}", e,
stackTrace);
}
if (metadataUpdate != null || locationUpdate != null) {
await UpdateProperty(_c)(
account,
file,
metadata: metadataUpdate,
location: locationUpdate,
);
yield file;
}
// slow down a bit to give some space for the main isolate
await Future.delayed(const Duration(milliseconds: 10));
} on InterruptedException catch (_) {
return;
} catch (e, stackTrace) {
_log.severe("[call] Failed while updating metadata: ${file.path}", e,
stackTrace);
yield ExceptionEvent(e, stackTrace);
}
}
}
void stop() {
_shouldRun = false;
}
Future<void> _ensureWifi() async {
var count = 0;
while (await configProvider.isWifiOnly() &&
!await connectivity_util.isWifi()) {
if (!_shouldRun) {
throw const InterruptedException();
}
// give a chance to reconnect with the WiFi network
if (++count >= 6) {
KiwiContainer().resolve<EventBus>().fire(
const MetadataTaskStateChangedEvent(
MetadataTaskState.waitingForWifi));
}
await Future.delayed(const Duration(seconds: 5));
}
}
Future<void> _ensureBattery() async {
while (await Battery().batteryLevel <= 15) {
if (!_shouldRun) {
throw const InterruptedException();
}
KiwiContainer().resolve<EventBus>().fire(
const MetadataTaskStateChangedEvent(MetadataTaskState.lowBattery));
await Future.delayed(const Duration(seconds: 5));
}
}
final DiContainer _c;
final UpdateMissingMetadataConfigProvider configProvider;
final ReverseGeocoder geocoder;
bool _shouldRun = true;
}

View file

@ -1,15 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'update_missing_metadata.dart';
// **************************************************************************
// NpLogGenerator
// **************************************************************************
extension _$UpdateMissingMetadataNpLog on UpdateMissingMetadata {
// ignore: unused_element
Logger get _log => log;
static final log =
Logger("use_case.update_missing_metadata.UpdateMissingMetadata");
}

View file

@ -1,11 +1,8 @@
import 'package:event_bus/event_bus.dart';
import 'package:kiwi/kiwi.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/file/repo.dart';
import 'package:nc_photos/entity/file_descriptor.dart';
import 'package:nc_photos/event/event.dart';
import 'package:np_codegen/np_codegen.dart';
import 'package:np_common/or_null.dart';
@ -13,7 +10,11 @@ part 'update_property.g.dart';
@npLog
class UpdateProperty {
const UpdateProperty(this._c);
const UpdateProperty({
required this.fileRepo,
});
final FileRepo2 fileRepo;
Future<void> call(
Account account,
@ -34,17 +35,7 @@ class UpdateProperty {
return;
}
await _c.fileRepo2.updateProperty(
account,
file,
metadata: metadata,
isArchived: isArchived,
overrideDateTime: overrideDateTime,
favorite: favorite,
location: location,
);
_notify(
await fileRepo.updateProperty(
account,
file,
metadata: metadata,
@ -54,40 +45,6 @@ class UpdateProperty {
location: location,
);
}
@Deprecated("legacy")
void _notify(
Account account,
FileDescriptor file, {
OrNull<Metadata>? metadata,
OrNull<bool>? isArchived,
OrNull<DateTime>? overrideDateTime,
bool? favorite,
OrNull<ImageLocation>? location,
}) {
int properties = 0;
if (metadata != null) {
properties |= FilePropertyUpdatedEvent.propMetadata;
}
if (isArchived != null) {
properties |= FilePropertyUpdatedEvent.propIsArchived;
}
if (overrideDateTime != null) {
properties |= FilePropertyUpdatedEvent.propOverrideDateTime;
}
if (favorite != null) {
properties |= FilePropertyUpdatedEvent.propFavorite;
}
if (location != null) {
properties |= FilePropertyUpdatedEvent.propImageLocation;
}
assert(properties != 0);
KiwiContainer()
.resolve<EventBus>()
.fire(FilePropertyUpdatedEvent(account, file, properties));
}
final DiContainer _c;
}
extension UpdatePropertyExtension on UpdateProperty {

View file

@ -0,0 +1,41 @@
import 'package:nc_photos/connectivity_util.dart' as connectivity_util;
import 'package:nc_photos/exception.dart';
import 'package:nc_photos/service/service.dart';
import 'package:rxdart/rxdart.dart';
class WifiEnsurer {
WifiEnsurer({
this.interrupter,
}) {
interrupter?.listen((event) {
_shouldRun = false;
});
}
Future<void> call() async {
var count = 0;
while (await ServiceConfig.isProcessExifWifiOnly() &&
!await connectivity_util.isWifi()) {
if (!_shouldRun) {
throw const InterruptedException();
}
// give a chance to reconnect with the WiFi network
if (++count >= 6) {
if (!_isWaiting.value) {
_isWaiting.add(true);
}
}
await Future.delayed(const Duration(seconds: 5));
}
if (_isWaiting.value) {
_isWaiting.add(false);
}
}
ValueStream<bool> get isWaiting => _isWaiting.stream;
final Stream<void>? interrupter;
var _shouldRun = true;
final _isWaiting = BehaviorSubject.seeded(false);
}

View file

@ -536,11 +536,7 @@ class _Bloc extends Bloc<_Event, _State>
personsController: personsController,
personProvider: accountPrefController.personProviderValue,
);
if (!serverController.isSupported(ServerFeature.ncMetadata)) {
metadataController.kickstart();
} else {
_log.info("[_syncRemote] Skipping metadata service");
}
metadataController.kickstart();
_log.info(
"[_syncRemote] Elapsed time: ${stopwatch.elapsedMilliseconds}ms");
});

View file

@ -6,7 +6,7 @@ import 'package:nc_photos/app_localizations.dart';
import 'package:nc_photos/bloc_util.dart';
import 'package:nc_photos/controller/pref_controller.dart';
import 'package:nc_photos/exception_event.dart';
import 'package:nc_photos/service.dart';
import 'package:nc_photos/service/service.dart';
import 'package:nc_photos/snack_bar_manager.dart';
import 'package:nc_photos/widget/page_visibility_mixin.dart';
import 'package:np_codegen/np_codegen.dart';

View file

@ -505,7 +505,7 @@ class _ViewerDetailPaneState extends State<ViewerDetailPane> {
return;
}
try {
await UpdateProperty(_c)
await UpdateProperty(fileRepo: _c.fileRepo2)
.updateOverrideDateTime(widget.account, _file!, value);
if (mounted) {
setState(() {

View file

@ -205,7 +205,7 @@ Future<void> _updaterIdentical() async {
await util.insertDirRelation(c.sqliteDb, account, files[2], [files[3]]);
});
final updater = FileSqliteCacheUpdater(c);
final updater = FileSqliteCacheUpdater(c.npDb);
await updater(account, files[0], remote: files.slice(0, 3));
expect(
await util.listSqliteDbFiles(c.sqliteDb),
@ -240,7 +240,7 @@ Future<void> _updaterNewFile() async {
await util.insertDirRelation(c.sqliteDb, account, files[2], [files[3]]);
});
final updater = FileSqliteCacheUpdater(c);
final updater = FileSqliteCacheUpdater(c.npDb);
await updater(account, files[0], remote: [...files.slice(0, 3), newFile]);
expect(
await util.listSqliteDbFiles(c.sqliteDb),
@ -271,7 +271,7 @@ Future<void> _updaterDeleteFile() async {
await util.insertDirRelation(c.sqliteDb, account, files[2], [files[3]]);
});
final updater = FileSqliteCacheUpdater(c);
final updater = FileSqliteCacheUpdater(c.npDb);
await updater(account, files[0], remote: [files[0], files[2]]);
expect(
await util.listSqliteDbFiles(c.sqliteDb),
@ -305,7 +305,7 @@ Future<void> _updaterDeleteDir() async {
await util.insertDirRelation(c.sqliteDb, account, files[2], [files[3]]);
});
final updater = FileSqliteCacheUpdater(c);
final updater = FileSqliteCacheUpdater(c.npDb);
await updater(account, files[0], remote: files.slice(0, 2));
expect(
await util.listSqliteDbFiles(c.sqliteDb),
@ -343,7 +343,7 @@ Future<void> _updaterUpdateFile() async {
await util.insertDirRelation(c.sqliteDb, account, files[2], [files[3]]);
});
final updater = FileSqliteCacheUpdater(c);
final updater = FileSqliteCacheUpdater(c.npDb);
await updater(account, files[0],
remote: [files[0], newFile, ...files.slice(2)]);
expect(
@ -382,7 +382,7 @@ Future<void> _updaterNewSharedFile() async {
await util.insertDirRelation(c.sqliteDb, account, files[2], [files[3]]);
});
final updater = FileSqliteCacheUpdater(c);
final updater = FileSqliteCacheUpdater(c.npDb);
await updater(user1Account, user1Files[0], remote: user1Files);
expect(
await util.listSqliteDbFiles(c.sqliteDb),
@ -419,7 +419,7 @@ Future<void> _updaterNewSharedDir() async {
await util.insertDirRelation(c.sqliteDb, account, files[2], [files[3]]);
});
final updater = FileSqliteCacheUpdater(c);
final updater = FileSqliteCacheUpdater(c.npDb);
await updater(user1Account, user1Files[0], remote: user1Files);
expect(
await util.listSqliteDbFiles(c.sqliteDb),
@ -461,7 +461,7 @@ Future<void> _updaterDeleteSharedFile() async {
c.sqliteDb, user1Account, user1Files[0], [user1Files[1]]);
});
final updater = FileSqliteCacheUpdater(c);
final updater = FileSqliteCacheUpdater(c.npDb);
await updater(user1Account, user1Files[0], remote: [user1Files[0]]);
expect(
await util.listSqliteDbFiles(c.sqliteDb),
@ -504,7 +504,7 @@ Future<void> _updaterDeleteSharedDir() async {
c.sqliteDb, user1Account, user1Files[0], [user1Files[1]]);
});
final updater = FileSqliteCacheUpdater(c);
final updater = FileSqliteCacheUpdater(c.npDb);
await updater(user1Account, user1Files[0], remote: [user1Files[0]]);
expect(
await util.listSqliteDbFiles(c.sqliteDb),
@ -541,7 +541,7 @@ Future<void> _updaterTooManyFiles() async {
await util.insertDirRelation(c.sqliteDb, account, files[2], files.slice(3));
});
final updater = FileSqliteCacheUpdater(c);
final updater = FileSqliteCacheUpdater(c.npDb);
await updater(account, files[2], remote: [...files.slice(2), ...newFiles]);
// we are testing to make sure the above function won't throw, so nothing to
// expect here
@ -574,12 +574,12 @@ Future<void> _updaterMovedFileToFront() async {
final movedFile = files[3].copyWith(
path: "remote.php/dav/files/admin/test1/test1.jpg",
);
await FileSqliteCacheUpdater(c)(
await FileSqliteCacheUpdater(c.npDb)(
account,
files[1],
remote: [files[1], movedFile],
);
await FileSqliteCacheUpdater(c)(
await FileSqliteCacheUpdater(c.npDb)(
account,
files[2],
remote: [files[2]],
@ -621,12 +621,12 @@ Future<void> _updaterMovedFileToBehind() async {
final movedFile = files[3].copyWith(
path: "remote.php/dav/files/admin/test2/test1.jpg",
);
await FileSqliteCacheUpdater(c)(
await FileSqliteCacheUpdater(c.npDb)(
account,
files[1],
remote: [files[1]],
);
await FileSqliteCacheUpdater(c)(
await FileSqliteCacheUpdater(c.npDb)(
account,
files[2],
remote: [files[2], movedFile],

View file

@ -1,5 +1,7 @@
library np_collection;
export 'package:quiver/iterables.dart' show partition;
export 'src/iterable_extension.dart';
export 'src/iterator_extension.dart';
export 'src/list_extension.dart';

View file

@ -201,6 +201,19 @@ class DbFilesMemory {
final Map<int, List<DbFileDescriptor>> memories;
}
@genCopyWith
@toString
class DbFileMissingMetadataResult {
const DbFileMissingMetadataResult({
required this.items,
});
@override
String toString() => _$toString();
final List<({int fileId, String relativePath})> items;
}
@npLog
abstract class NpDb {
factory NpDb() => NpDbSqlite();
@ -354,6 +367,13 @@ abstract class NpDb {
required String ownerId,
});
/// Return files without metadata
Future<DbFileMissingMetadataResult> getFilesByMissingMetadata({
required DbAccount account,
required List<String> mimes,
required String ownerId,
});
/// Delete a file or dir from db
Future<void> deleteFile({
required DbAccount account,

View file

@ -81,6 +81,31 @@ extension $DbFilesMemoryCopyWith on DbFilesMemory {
_$DbFilesMemoryCopyWithWorkerImpl(this);
}
abstract class $DbFileMissingMetadataResultCopyWithWorker {
DbFileMissingMetadataResult call(
{List<({int fileId, String relativePath})>? items});
}
class _$DbFileMissingMetadataResultCopyWithWorkerImpl
implements $DbFileMissingMetadataResultCopyWithWorker {
_$DbFileMissingMetadataResultCopyWithWorkerImpl(this.that);
@override
DbFileMissingMetadataResult call({dynamic items}) {
return DbFileMissingMetadataResult(
items:
items as List<({int fileId, String relativePath})>? ?? that.items);
}
final DbFileMissingMetadataResult that;
}
extension $DbFileMissingMetadataResultCopyWith on DbFileMissingMetadataResult {
$DbFileMissingMetadataResultCopyWithWorker get copyWith => _$copyWith;
$DbFileMissingMetadataResultCopyWithWorker get _$copyWith =>
_$DbFileMissingMetadataResultCopyWithWorkerImpl(this);
}
// **************************************************************************
// NpLogGenerator
// **************************************************************************
@ -151,3 +176,10 @@ extension _$DbFilesMemoryToString on DbFilesMemory {
return "DbFilesMemory {memories: $memories}";
}
}
extension _$DbFileMissingMetadataResultToString on DbFileMissingMetadataResult {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "DbFileMissingMetadataResult {items: [length: ${items.length}]}";
}
}

View file

@ -343,7 +343,7 @@ extension SqliteDbFileExtension on SqliteDb {
String? ownerId,
}) async {
_log.info(
"[countFiles] isMissingMetadata: $isMissingMetadata, mimes: $mimes");
"[countFiles] isMissingMetadata: $isMissingMetadata, mimes: $mimes, ownerId: $ownerId");
Expression<bool>? filter;
if (isMissingMetadata != null) {
if (isMissingMetadata) {
@ -387,6 +387,69 @@ extension SqliteDbFileExtension on SqliteDb {
return await query.map((r) => r.read(count)!).getSingle();
}
Future<List<({int fileId, String relativePath})>>
queryFileIdPathsByMissingMetadata({
required ByAccount account,
bool? isMissingMetadata,
List<String>? mimes,
String? ownerId,
int? offset,
int? limit,
}) async {
_log.info(
"[queryFileIdPathsByMissingMetadata] isMissingMetadata: $isMissingMetadata, mimes: $mimes, ownerId: $ownerId");
final query = selectOnly(files).join([
innerJoin(accountFiles, accountFiles.file.equalsExp(files.rowId),
useColumns: false),
if (account.dbAccount != null) ...[
innerJoin(accounts, accounts.rowId.equalsExp(accountFiles.account),
useColumns: false),
innerJoin(servers, servers.rowId.equalsExp(accounts.server),
useColumns: false),
],
leftOuterJoin(images, images.accountFile.equalsExp(accountFiles.rowId),
useColumns: false),
leftOuterJoin(imageLocations,
imageLocations.accountFile.equalsExp(accountFiles.rowId),
useColumns: false),
]);
query.addColumns([files.fileId, accountFiles.relativePath]);
if (account.sqlAccount != null) {
query.where(accountFiles.account.equals(account.sqlAccount!.rowId));
} else if (account.dbAccount != null) {
query
..where(servers.address.equals(account.dbAccount!.serverAddress))
..where(accounts.userId
.equals(account.dbAccount!.userId.toCaseInsensitiveString()));
}
if (mimes != null) {
query.where(files.contentType.isIn(mimes));
}
if (ownerId != null) {
query.where(files.ownerId.equals(ownerId));
}
if (isMissingMetadata != null) {
if (isMissingMetadata) {
query.where(
images.lastUpdated.isNull() | imageLocations.version.isNull());
} else {
query.where(images.lastUpdated.isNotNull() &
imageLocations.version.isNotNull());
}
}
query.orderBy([OrderingTerm.desc(files.fileId)]);
if (limit != null) {
query.limit(limit, offset: offset);
}
return await query
.map((r) => (
fileId: r.read(files.fileId)!,
relativePath: r.read(accountFiles.relativePath)!,
))
.get();
}
Future<List<FileDescriptor>> queryFileDescriptors({
required ByAccount account,
List<int>? fileIds,

View file

@ -395,6 +395,34 @@ class NpDbSqlite implements NpDb {
});
}
@override
Future<DbFileMissingMetadataResult> getFilesByMissingMetadata({
required DbAccount account,
required List<String> mimes,
required String ownerId,
}) async {
return _db.use((db) async {
final results = <({int fileId, String relativePath})>[];
var i = 0;
while (true) {
final sqlObjs = await db.queryFileIdPathsByMissingMetadata(
account: ByAccount.db(account),
isMissingMetadata: true,
mimes: mimes,
ownerId: ownerId,
limit: 10000,
offset: i,
);
if (sqlObjs.isEmpty) {
break;
}
results.addAll(sqlObjs);
i += 10000;
}
return DbFileMissingMetadataResult(items: results);
});
}
@override
Future<void> deleteFile({
required DbAccount account,