nc-photos/lib/entity/album.dart

464 lines
15 KiB
Dart
Raw Normal View History

2021-04-10 06:28:12 +02:00
import 'dart:convert';
import 'dart:math';
2021-04-15 20:44:25 +02:00
import 'package:equatable/equatable.dart';
2021-04-10 06:28:12 +02:00
import 'package:idb_sqflite/idb_sqflite.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/app_db.dart';
2021-06-26 13:51:13 +02:00
import 'package:nc_photos/entity/album/cover_provider.dart';
2021-07-05 09:54:01 +02:00
import 'package:nc_photos/entity/album/item.dart';
2021-06-24 18:26:56 +02:00
import 'package:nc_photos/entity/album/provider.dart';
2021-07-07 20:40:43 +02:00
import 'package:nc_photos/entity/album/sort_provider.dart';
2021-06-24 16:54:41 +02:00
import 'package:nc_photos/entity/album/upgrader.dart';
2021-04-10 06:28:12 +02:00
import 'package:nc_photos/entity/file.dart';
2021-05-24 09:09:25 +02:00
import 'package:nc_photos/entity/file/data_source.dart';
2021-04-10 06:28:12 +02:00
import 'package:nc_photos/exception.dart';
2021-04-26 18:26:49 +02:00
import 'package:nc_photos/int_util.dart' as int_util;
2021-04-10 06:28:12 +02:00
import 'package:nc_photos/iterable_extension.dart';
2021-07-22 08:15:41 +02:00
import 'package:nc_photos/or_null.dart';
2021-05-23 19:18:24 +02:00
import 'package:nc_photos/remote_storage_util.dart' as remote_storage_util;
2021-08-06 19:11:00 +02:00
import 'package:nc_photos/type.dart';
2021-04-10 06:28:12 +02:00
import 'package:nc_photos/use_case/get_file_binary.dart';
import 'package:nc_photos/use_case/ls.dart';
import 'package:nc_photos/use_case/put_file_binary.dart';
2021-04-26 18:26:49 +02:00
import 'package:quiver/iterables.dart';
import 'package:tuple/tuple.dart';
2021-04-10 06:28:12 +02:00
2021-07-18 19:22:02 +02:00
bool isAlbumFile(Account account, File file) =>
file.path.startsWith(remote_storage_util.getRemoteAlbumsDir(account));
2021-04-10 06:28:12 +02:00
/// Immutable object that represents an album
2021-04-15 20:44:25 +02:00
class Album with EquatableMixin {
2021-04-10 06:28:12 +02:00
Album({
2021-07-23 22:05:57 +02:00
DateTime? lastUpdated,
required this.name,
required this.provider,
required this.coverProvider,
required this.sortProvider,
2021-04-10 06:28:12 +02:00
this.albumFile,
2021-07-23 22:05:57 +02:00
}) : this.lastUpdated = (lastUpdated ?? DateTime.now()).toUtc();
2021-04-10 06:28:12 +02:00
2021-07-23 22:05:57 +02:00
static Album? fromJson(
2021-08-06 19:11:00 +02:00
JsonObj json, {
2021-07-23 22:05:57 +02:00
required AlbumUpgraderV1? upgraderV1,
required AlbumUpgraderV2? upgraderV2,
required AlbumUpgraderV3? upgraderV3,
2021-04-10 06:28:12 +02:00
}) {
2021-06-24 16:54:41 +02:00
final jsonVersion = json["version"];
2021-08-06 19:11:00 +02:00
JsonObj? result = json;
2021-06-24 16:54:41 +02:00
if (jsonVersion < 2) {
2021-07-23 22:05:57 +02:00
result = upgraderV1?.call(result);
if (result == null) {
2021-06-24 16:54:41 +02:00
_log.info("[fromJson] Version $jsonVersion not compatible");
return null;
}
}
2021-06-24 18:26:56 +02:00
if (jsonVersion < 3) {
2021-07-23 22:05:57 +02:00
result = upgraderV2?.call(result);
if (result == null) {
2021-06-24 18:26:56 +02:00
_log.info("[fromJson] Version $jsonVersion not compatible");
return null;
}
}
2021-07-07 20:40:43 +02:00
if (jsonVersion < 4) {
2021-07-23 22:05:57 +02:00
result = upgraderV3?.call(result);
if (result == null) {
2021-07-07 20:40:43 +02:00
_log.info("[fromJson] Version $jsonVersion not compatible");
return null;
}
}
2021-06-24 16:54:41 +02:00
return Album(
2021-07-23 22:05:57 +02:00
lastUpdated: result["lastUpdated"] == null
2021-04-10 06:28:12 +02:00
? null
2021-07-23 22:05:57 +02:00
: DateTime.parse(result["lastUpdated"]),
name: result["name"],
2021-06-24 18:26:56 +02:00
provider:
2021-07-23 22:05:57 +02:00
AlbumProvider.fromJson(result["provider"].cast<String, dynamic>()),
2021-06-26 13:51:13 +02:00
coverProvider: AlbumCoverProvider.fromJson(
2021-07-23 22:05:57 +02:00
result["coverProvider"].cast<String, dynamic>()),
2021-07-07 20:40:43 +02:00
sortProvider: AlbumSortProvider.fromJson(
2021-07-23 22:05:57 +02:00
result["sortProvider"].cast<String, dynamic>()),
albumFile: result["albumFile"] == null
2021-04-10 06:28:12 +02:00
? null
2021-07-23 22:05:57 +02:00
: File.fromJson(result["albumFile"].cast<String, dynamic>()),
2021-04-10 06:28:12 +02:00
);
}
@override
2021-06-12 17:18:41 +02:00
toString({bool isDeep = false}) {
2021-04-10 06:28:12 +02:00
return "$runtimeType {"
"lastUpdated: $lastUpdated, "
"name: $name, "
2021-06-24 18:26:56 +02:00
"provider: ${provider.toString(isDeep: isDeep)}, "
2021-06-26 13:51:13 +02:00
"coverProvider: $coverProvider, "
2021-07-07 20:40:43 +02:00
"sortProvider: $sortProvider, "
2021-04-10 06:28:12 +02:00
"albumFile: $albumFile, "
"}";
}
/// Return a copy with specified field modified
///
2021-07-22 08:15:41 +02:00
/// [lastUpdated] is handled differently where if not set, the current time
/// will be used. In order to keep [lastUpdated], you must explicitly assign
/// it with value from this or a null value
2021-04-10 06:28:12 +02:00
Album copyWith({
2021-07-23 22:05:57 +02:00
OrNull<DateTime>? lastUpdated,
String? name,
AlbumProvider? provider,
AlbumCoverProvider? coverProvider,
AlbumSortProvider? sortProvider,
File? albumFile,
2021-04-10 06:28:12 +02:00
}) {
return Album(
2021-07-22 08:15:41 +02:00
lastUpdated:
lastUpdated == null ? null : (lastUpdated.obj ?? this.lastUpdated),
2021-04-10 06:28:12 +02:00
name: name ?? this.name,
2021-06-24 18:26:56 +02:00
provider: provider ?? this.provider,
2021-06-26 13:51:13 +02:00
coverProvider: coverProvider ?? this.coverProvider,
2021-07-18 19:22:45 +02:00
sortProvider: sortProvider ?? this.sortProvider,
2021-04-10 06:28:12 +02:00
albumFile: albumFile ?? this.albumFile,
);
}
2021-08-06 19:11:00 +02:00
JsonObj toRemoteJson() {
2021-04-10 06:28:12 +02:00
return {
"version": version,
"lastUpdated": lastUpdated.toIso8601String(),
"name": name,
2021-06-24 18:26:56 +02:00
"provider": provider.toJson(),
2021-06-26 13:51:13 +02:00
"coverProvider": coverProvider.toJson(),
2021-07-07 20:40:43 +02:00
"sortProvider": sortProvider.toJson(),
2021-04-10 06:28:12 +02:00
// ignore albumFile
};
}
2021-08-06 19:11:00 +02:00
JsonObj toAppDbJson() {
2021-04-10 06:28:12 +02:00
return {
"version": version,
"lastUpdated": lastUpdated.toIso8601String(),
"name": name,
2021-06-24 18:26:56 +02:00
"provider": provider.toJson(),
2021-06-26 13:51:13 +02:00
"coverProvider": coverProvider.toJson(),
2021-07-07 20:40:43 +02:00
"sortProvider": sortProvider.toJson(),
2021-07-23 22:05:57 +02:00
if (albumFile != null) "albumFile": albumFile!.toJson(),
2021-04-10 06:28:12 +02:00
};
}
2021-04-15 20:44:25 +02:00
@override
get props => [
lastUpdated,
name,
2021-06-24 18:26:56 +02:00
provider,
2021-06-26 13:51:13 +02:00
coverProvider,
2021-07-07 20:40:43 +02:00
sortProvider,
2021-04-15 20:44:25 +02:00
albumFile,
];
2021-04-10 06:28:12 +02:00
final DateTime lastUpdated;
final String name;
2021-06-24 18:26:56 +02:00
final AlbumProvider provider;
2021-06-26 13:51:13 +02:00
final AlbumCoverProvider coverProvider;
2021-07-07 20:40:43 +02:00
final AlbumSortProvider sortProvider;
2021-04-10 06:28:12 +02:00
/// How is this album stored on server
///
/// This field is typically only meaningful when returned by [AlbumRepo.get]
2021-07-23 22:05:57 +02:00
final File? albumFile;
2021-04-10 06:28:12 +02:00
/// versioning of this class, use to upgrade old persisted album
2021-07-07 20:40:43 +02:00
static const version = 4;
2021-04-10 06:28:12 +02:00
}
class AlbumRepo {
AlbumRepo(this.dataSrc);
/// See [AlbumDataSource.get]
Future<Album> get(Account account, File albumFile) =>
this.dataSrc.get(account, albumFile);
/// See [AlbumDataSource.create]
Future<Album> create(Account account, Album album) =>
this.dataSrc.create(account, album);
/// See [AlbumDataSource.update]
Future<void> update(Account account, Album album) =>
this.dataSrc.update(account, album);
/// See [AlbumDataSource.cleanUp]
Future<void> cleanUp(
Account account, String rootDir, List<File> albumFiles) =>
this.dataSrc.cleanUp(account, rootDir, albumFiles);
2021-04-10 06:28:12 +02:00
final AlbumDataSource dataSrc;
}
abstract class AlbumDataSource {
/// Return the album defined by [albumFile]
Future<Album> get(Account account, File albumFile);
// Create a new album
Future<Album> create(Account account, Album album);
/// Update an album
Future<void> update(Account account, Album album);
/// Clean up cached albums
///
/// Remove dangling albums in cache not listed in [albumFiles] and located
/// inside [rootDir]. Do nothing if this data source does not cache previous
/// results
Future<void> cleanUp(Account account, String rootDir, List<File> albumFiles);
2021-04-10 06:28:12 +02:00
}
class AlbumRemoteDataSource implements AlbumDataSource {
@override
get(Account account, File albumFile) async {
_log.info("[get] ${albumFile.path}");
final fileRepo = FileRepo(FileWebdavDataSource());
final data = await GetFileBinary(fileRepo)(account, albumFile);
try {
2021-06-24 16:54:41 +02:00
return Album.fromJson(
jsonDecode(utf8.decode(data)),
upgraderV1: AlbumUpgraderV1(),
2021-06-24 18:26:56 +02:00
upgraderV2: AlbumUpgraderV2(),
2021-07-07 20:40:43 +02:00
upgraderV3: AlbumUpgraderV3(),
2021-07-23 22:05:57 +02:00
)!
.copyWith(
2021-07-22 08:15:41 +02:00
lastUpdated: OrNull(null),
albumFile: albumFile,
);
2021-04-10 06:28:12 +02:00
} catch (e, stacktrace) {
dynamic d = data;
try {
d = utf8.decode(data);
} catch (_) {}
_log.severe("[get] Invalid json data: $d", e, stacktrace);
throw FormatException("Invalid album format");
}
}
@override
create(Account account, Album album) async {
_log.info("[create]");
final fileName = _makeAlbumFileName();
2021-05-23 19:18:24 +02:00
final filePath =
"${remote_storage_util.getRemoteAlbumsDir(account)}/$fileName";
2021-04-10 06:28:12 +02:00
final fileRepo = FileRepo(FileWebdavDataSource());
2021-07-23 22:05:57 +02:00
await PutFileBinary(fileRepo)(account, filePath,
Utf8Encoder().convert(jsonEncode(album.toRemoteJson())),
shouldCreateMissingDir: true);
2021-04-10 06:28:12 +02:00
// query album file
final list = await Ls(fileRepo)(account, File(path: filePath),
shouldExcludeRootDir: false);
return album.copyWith(albumFile: list.first);
}
@override
update(Account account, Album album) async {
2021-07-23 22:05:57 +02:00
_log.info("[update] ${album.albumFile!.path}");
2021-04-10 06:28:12 +02:00
final fileRepo = FileRepo(FileWebdavDataSource());
2021-07-23 22:05:57 +02:00
await PutFileBinary(fileRepo)(account, album.albumFile!.path,
Utf8Encoder().convert(jsonEncode(album.toRemoteJson())));
2021-04-10 06:28:12 +02:00
}
@override
cleanUp(Account account, String rootDir, List<File> albumFiles) async {}
2021-04-10 06:28:12 +02:00
String _makeAlbumFileName() {
// just make up something
final timestamp = DateTime.now().millisecondsSinceEpoch;
final random = Random().nextInt(0xFFFFFF);
return "${timestamp.toRadixString(16)}-${random.toRadixString(16).padLeft(6, '0')}.json";
}
static final _log = Logger("entity.album.AlbumRemoteDataSource");
}
class AlbumAppDbDataSource implements AlbumDataSource {
@override
get(Account account, File albumFile) {
_log.info("[get] ${albumFile.path}");
return AppDb.use((db) async {
final transaction = db.transaction(AppDb.albumStoreName, idbModeReadOnly);
final store = transaction.objectStore(AppDb.albumStoreName);
2021-04-26 18:26:49 +02:00
final index = store.index(AppDbAlbumEntry.indexName);
final path = AppDbAlbumEntry.toPathFromFile(account, albumFile);
2021-04-26 18:26:49 +02:00
final range = KeyRange.bound([path, 0], [path, int_util.int32Max]);
final List results = await index.getAll(range);
2021-07-23 22:05:57 +02:00
if (results.isNotEmpty == true) {
2021-04-26 18:26:49 +02:00
final entries = results
.map((e) => AppDbAlbumEntry.fromJson(e.cast<String, dynamic>()));
2021-06-24 18:26:56 +02:00
if (entries.length > 1) {
final items = entries.map((e) {
_log.info("[get] ${e.path}[${e.index}]");
return AlbumStaticProvider.of(e.album).items;
}).reduce((value, element) => value + element);
return entries.first.album.copyWith(
2021-07-22 08:15:41 +02:00
lastUpdated: OrNull(null),
2021-06-24 18:26:56 +02:00
provider: AlbumStaticProvider(
items: items,
),
);
} else {
return entries.first.album;
}
2021-04-10 06:28:12 +02:00
} else {
2021-04-26 18:26:49 +02:00
throw CacheNotFoundException("No entry: $path");
2021-04-10 06:28:12 +02:00
}
});
}
@override
create(Account account, Album album) async {
_log.info("[create]");
throw UnimplementedError();
}
@override
update(Account account, Album album) {
2021-07-23 22:05:57 +02:00
_log.info("[update] ${album.albumFile!.path}");
2021-04-10 06:28:12 +02:00
return AppDb.use((db) async {
final transaction =
db.transaction(AppDb.albumStoreName, idbModeReadWrite);
final store = transaction.objectStore(AppDb.albumStoreName);
2021-04-26 18:26:49 +02:00
await _cacheAlbum(store, account, album);
2021-04-10 06:28:12 +02:00
});
}
@override
cleanUp(Account account, String rootDir, List<File> albumFiles) async {}
2021-04-10 06:28:12 +02:00
static final _log = Logger("entity.album.AlbumAppDbDataSource");
}
class AlbumCachedDataSource implements AlbumDataSource {
@override
get(Account account, File albumFile) async {
try {
final cache = await _appDbSrc.get(account, albumFile);
2021-07-23 22:05:57 +02:00
if (cache.albumFile!.etag?.isNotEmpty == true &&
cache.albumFile!.etag == albumFile.etag) {
2021-04-10 06:28:12 +02:00
// cache is good
2021-04-26 18:26:49 +02:00
_log.fine(
"[get] etag matched for ${AppDbAlbumEntry.toPathFromFile(account, albumFile)}");
2021-04-10 06:28:12 +02:00
return cache;
}
2021-04-26 18:26:49 +02:00
_log.info(
"[get] Remote content updated for ${AppDbAlbumEntry.toPathFromFile(account, albumFile)}");
2021-04-27 22:06:16 +02:00
} on CacheNotFoundException catch (_) {
// normal when there's no cache
2021-04-10 06:28:12 +02:00
} catch (e, stacktrace) {
2021-04-27 22:06:16 +02:00
_log.shout("[get] Cache failure", e, stacktrace);
2021-04-10 06:28:12 +02:00
}
// no cache
final remote = await _remoteSrc.get(account, albumFile);
2021-04-26 18:26:49 +02:00
await _cacheResult(account, remote);
2021-04-10 06:28:12 +02:00
return remote;
}
@override
update(Account account, Album album) async {
await _remoteSrc.update(account, album);
await _appDbSrc.update(account, album);
}
@override
create(Account account, Album album) => _remoteSrc.create(account, album);
@override
cleanUp(Account account, String rootDir, List<File> albumFiles) async {
2021-04-10 06:28:12 +02:00
AppDb.use((db) async {
final transaction =
db.transaction(AppDb.albumStoreName, idbModeReadWrite);
final store = transaction.objectStore(AppDb.albumStoreName);
2021-04-26 18:26:49 +02:00
final index = store.index(AppDbAlbumEntry.indexName);
final rootPath = AppDbAlbumEntry.toPath(account, rootDir);
2021-04-26 18:26:49 +02:00
final range = KeyRange.bound(
["$rootPath/", 0], ["$rootPath/\uffff", int_util.int32Max]);
final danglingKeys = await index
2021-04-10 06:28:12 +02:00
// get all albums for this account
.openKeyCursor(range: range, autoAdvance: true)
2021-04-26 18:26:49 +02:00
.map((cursor) => Tuple2((cursor.key as List)[0], cursor.primaryKey))
2021-04-10 06:28:12 +02:00
// and pick the dangling ones
.where((pair) => !albumFiles.any(
(f) => pair.item1 == AppDbAlbumEntry.toPathFromFile(account, f)))
2021-04-26 18:26:49 +02:00
// map to primary keys
.map((pair) => pair.item2)
2021-04-10 06:28:12 +02:00
.toList();
for (final k in danglingKeys) {
_log.fine("[cleanUp] Removing DB entry: $k");
await store.delete(k);
}
});
}
2021-04-26 18:26:49 +02:00
Future<void> _cacheResult(Account account, Album result) {
2021-04-10 06:28:12 +02:00
return AppDb.use((db) async {
final transaction =
db.transaction(AppDb.albumStoreName, idbModeReadWrite);
final store = transaction.objectStore(AppDb.albumStoreName);
2021-04-26 18:26:49 +02:00
await _cacheAlbum(store, account, result);
2021-04-10 06:28:12 +02:00
});
}
final _remoteSrc = AlbumRemoteDataSource();
final _appDbSrc = AlbumAppDbDataSource();
static final _log = Logger("entity.album.AlbumCachedDataSource");
}
2021-04-26 18:26:49 +02:00
Future<void> _cacheAlbum(
ObjectStore store, Account account, Album album) async {
final index = store.index(AppDbAlbumEntry.indexName);
2021-07-23 22:05:57 +02:00
final path = AppDbAlbumEntry.toPathFromFile(account, album.albumFile!);
2021-04-26 18:26:49 +02:00
final range = KeyRange.bound([path, 0], [path, int_util.int32Max]);
// count number of entries for this album
final count = await index.count(range);
2021-06-21 14:36:31 +02:00
2021-06-24 18:26:56 +02:00
// cut large album into smaller pieces, needed to workaround Android DB
// limitation
final entries = <AppDbAlbumEntry>[];
if (album.provider is AlbumStaticProvider) {
var albumItemLists = partition(
AlbumStaticProvider.of(album).items, AppDbAlbumEntry.maxDataSize)
.toList();
if (albumItemLists.isEmpty) {
albumItemLists = [<AlbumItem>[]];
}
entries.addAll(albumItemLists.withIndex().map((pair) => AppDbAlbumEntry(
path,
pair.item1,
album.copyWith(
2021-07-22 08:15:41 +02:00
lastUpdated: OrNull(null),
2021-06-24 18:26:56 +02:00
provider: AlbumStaticProvider(items: pair.item2),
))));
} else {
entries.add(AppDbAlbumEntry(path, 0, album));
2021-06-21 14:36:31 +02:00
}
2021-06-24 18:26:56 +02:00
for (final e in entries) {
_log.info("[_cacheAlbum] Caching ${e.path}[${e.index}]");
await store.put(e.toJson(),
2021-07-23 22:05:57 +02:00
AppDbAlbumEntry.toPrimaryKey(account, e.album.albumFile!, e.index));
2021-04-26 18:26:49 +02:00
}
2021-06-24 18:26:56 +02:00
if (count > entries.length) {
2021-04-26 18:26:49 +02:00
// index is 0-based
2021-06-24 18:26:56 +02:00
final rmRange =
KeyRange.bound([path, entries.length], [path, int_util.int32Max]);
2021-04-26 18:26:49 +02:00
final rmKeys = await index
.openKeyCursor(range: rmRange, autoAdvance: true)
.map((cursor) => cursor.primaryKey)
.toList();
for (final k in rmKeys) {
_log.fine("[_cacheAlbum] Removing DB entry: $k");
await store.delete(k);
}
}
}
2021-04-10 06:28:12 +02:00
2021-04-26 18:26:49 +02:00
final _log = Logger("entity.album");