Initial Flutter booru app
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
export 'di/backend_providers.dart';
|
||||
export 'models/collection.dart';
|
||||
export 'models/content_provider_config.dart';
|
||||
export 'models/favorite.dart';
|
||||
export 'models/post.dart';
|
||||
export 'models/provider_health.dart';
|
||||
export 'models/search_history.dart';
|
||||
export 'models/top_period_filter.dart';
|
||||
export 'providers/content_provider.dart';
|
||||
export 'providers/provider_manager.dart';
|
||||
export 'services/collection_service.dart';
|
||||
export 'services/favorite_service.dart';
|
||||
export 'services/feed_service.dart';
|
||||
export 'services/provider_check_service.dart';
|
||||
export 'services/search_service.dart';
|
||||
export 'services/settings_service.dart';
|
||||
@@ -0,0 +1,121 @@
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/cache/cache_service.dart';
|
||||
import '../../core/cache/image_cache_service.dart';
|
||||
import '../../core/database/app_database.dart';
|
||||
import '../../core/database/database_service.dart';
|
||||
import '../../core/http/network_info.dart';
|
||||
import '../providers/provider_factory.dart';
|
||||
import '../providers/provider_manager.dart';
|
||||
import '../repositories/collection_repository.dart';
|
||||
import '../repositories/favorite_repository.dart';
|
||||
import '../repositories/post_repository.dart';
|
||||
import '../repositories/provider_repository.dart';
|
||||
import '../repositories/search_repository.dart';
|
||||
import '../services/collection_service.dart';
|
||||
import '../services/favorite_service.dart';
|
||||
import '../services/feed_service.dart';
|
||||
import '../services/provider_check_service.dart';
|
||||
import '../services/search_service.dart';
|
||||
import '../services/settings_service.dart';
|
||||
|
||||
final appDatabaseProvider = FutureProvider<AppDatabase>((ref) async {
|
||||
final database = await AppDatabase.open();
|
||||
ref.onDispose(database.close);
|
||||
return database;
|
||||
});
|
||||
|
||||
final databaseServiceProvider = Provider<DatabaseService>((ref) {
|
||||
final database = ref.watch(appDatabaseProvider).requireValue;
|
||||
return DatabaseService(database);
|
||||
});
|
||||
|
||||
final connectivityProvider = Provider<Connectivity>((ref) => Connectivity());
|
||||
|
||||
final networkInfoProvider = Provider<NetworkInfo>((ref) {
|
||||
return NetworkInfo(ref.watch(connectivityProvider));
|
||||
});
|
||||
|
||||
final providerFactoryProvider = Provider<ProviderFactory>((ref) {
|
||||
return ProviderFactory();
|
||||
});
|
||||
|
||||
final postRepositoryProvider = Provider<PostRepository>((ref) {
|
||||
return PostRepository(ref.watch(databaseServiceProvider));
|
||||
});
|
||||
|
||||
final providerRepositoryProvider = Provider<ProviderRepository>((ref) {
|
||||
return ProviderRepository(ref.watch(databaseServiceProvider));
|
||||
});
|
||||
|
||||
final searchRepositoryProvider = Provider<SearchRepository>((ref) {
|
||||
return SearchRepository(ref.watch(databaseServiceProvider));
|
||||
});
|
||||
|
||||
final favoriteRepositoryProvider = Provider<FavoriteRepository>((ref) {
|
||||
return FavoriteRepository(
|
||||
ref.watch(databaseServiceProvider),
|
||||
ref.watch(postRepositoryProvider),
|
||||
);
|
||||
});
|
||||
|
||||
final collectionRepositoryProvider = Provider<CollectionRepository>((ref) {
|
||||
return CollectionRepository(
|
||||
ref.watch(databaseServiceProvider),
|
||||
ref.watch(postRepositoryProvider),
|
||||
);
|
||||
});
|
||||
|
||||
final cacheServiceProvider = Provider<CacheService>((ref) {
|
||||
return CacheService(ref.watch(databaseServiceProvider));
|
||||
});
|
||||
|
||||
final imageCacheServiceProvider = Provider<ImageCacheService>((ref) {
|
||||
return ImageCacheService();
|
||||
});
|
||||
|
||||
final providerManagerProvider = Provider<ProviderManager>((ref) {
|
||||
return ProviderManager(
|
||||
ref.watch(providerRepositoryProvider),
|
||||
ref.watch(providerFactoryProvider),
|
||||
);
|
||||
});
|
||||
|
||||
final feedServiceProvider = Provider<FeedService>((ref) {
|
||||
return FeedService(
|
||||
ref.watch(providerManagerProvider),
|
||||
ref.watch(cacheServiceProvider),
|
||||
ref.watch(settingsServiceProvider),
|
||||
);
|
||||
});
|
||||
|
||||
final searchServiceProvider = Provider<SearchService>((ref) {
|
||||
return SearchService(ref.watch(searchRepositoryProvider));
|
||||
});
|
||||
|
||||
final providerCheckServiceProvider = Provider<ProviderCheckService>((ref) {
|
||||
return ProviderCheckService(
|
||||
ref.watch(providerRepositoryProvider),
|
||||
ref.watch(providerFactoryProvider),
|
||||
ref.watch(providerManagerProvider),
|
||||
);
|
||||
});
|
||||
|
||||
final favoriteServiceProvider = Provider<FavoriteService>((ref) {
|
||||
return FavoriteService(
|
||||
ref.watch(favoriteRepositoryProvider),
|
||||
ref.watch(postRepositoryProvider),
|
||||
);
|
||||
});
|
||||
|
||||
final collectionServiceProvider = Provider<CollectionService>((ref) {
|
||||
return CollectionService(ref.watch(collectionRepositoryProvider));
|
||||
});
|
||||
|
||||
final settingsServiceProvider = Provider<SettingsService>((ref) {
|
||||
return SettingsService(
|
||||
ref.watch(databaseServiceProvider),
|
||||
providerRepository: ref.watch(providerRepositoryProvider),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import '../models/post.dart';
|
||||
|
||||
class DanbooruMapper {
|
||||
static List<Post> postsFromResponse(
|
||||
dynamic data, {
|
||||
required String providerId,
|
||||
required String providerName,
|
||||
}) {
|
||||
final items = data is List ? data : const [];
|
||||
return items
|
||||
.whereType<Map>()
|
||||
.map((item) => postFromJson(
|
||||
Map<String, dynamic>.from(item),
|
||||
providerId: providerId,
|
||||
providerName: providerName,
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
static Post postFromJson(
|
||||
Map<String, dynamic> json, {
|
||||
required String providerId,
|
||||
required String providerName,
|
||||
}) {
|
||||
final fileUrl = _string(json['file_url'] ?? json['large_file_url']);
|
||||
final sampleUrl =
|
||||
_string(json['large_file_url'] ?? json['sample_file_url'] ?? fileUrl);
|
||||
final previewUrl = _string(json['preview_file_url'] ?? sampleUrl);
|
||||
return Post(
|
||||
id: _string(json['id']),
|
||||
providerId: providerId,
|
||||
providerName: providerName,
|
||||
previewUrl: previewUrl,
|
||||
sampleUrl: sampleUrl,
|
||||
fileUrl: fileUrl,
|
||||
tags: _tags(json['tag_string'] ?? json['tags']),
|
||||
rating: _string(json['rating'], fallback: 'unknown'),
|
||||
width: _int(json['image_width'] ?? json['width']),
|
||||
height: _int(json['image_height'] ?? json['height']),
|
||||
source: _nullableString(json['source']),
|
||||
createdAt:
|
||||
DateTime.tryParse(_string(json['created_at'])) ?? DateTime.now(),
|
||||
fileType: _fileType(fileUrl),
|
||||
score: _int(json['score']),
|
||||
);
|
||||
}
|
||||
|
||||
static List<String> _tags(dynamic value) {
|
||||
if (value is List) return value.map((tag) => tag.toString()).toList();
|
||||
return _string(value)
|
||||
.split(RegExp(r'\s+'))
|
||||
.where((tag) => tag.trim().isNotEmpty)
|
||||
.toSet()
|
||||
.toList();
|
||||
}
|
||||
|
||||
static int _int(dynamic value) {
|
||||
if (value is int) return value;
|
||||
if (value is num) return value.toInt();
|
||||
return int.tryParse(value?.toString() ?? '') ?? 0;
|
||||
}
|
||||
|
||||
static String _string(dynamic value, {String fallback = ''}) {
|
||||
final text = value?.toString();
|
||||
return text == null || text.isEmpty ? fallback : text;
|
||||
}
|
||||
|
||||
static String? _nullableString(dynamic value) {
|
||||
final text = value?.toString();
|
||||
return text == null || text.isEmpty ? null : text;
|
||||
}
|
||||
|
||||
static String _fileType(String url) {
|
||||
final lower = url.toLowerCase();
|
||||
if (lower.endsWith('.webm') || lower.endsWith('.mp4')) return 'video';
|
||||
if (lower.endsWith('.gif')) return 'gif';
|
||||
if (lower.isEmpty) return 'unknown';
|
||||
return 'image';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import '../models/post.dart';
|
||||
|
||||
class GelbooruMapper {
|
||||
static List<Post> postsFromResponse(
|
||||
dynamic data, {
|
||||
required String providerId,
|
||||
required String providerName,
|
||||
}) {
|
||||
final items = _extractItems(data);
|
||||
return items
|
||||
.whereType<Map>()
|
||||
.map((item) => postFromJson(
|
||||
Map<String, dynamic>.from(item),
|
||||
providerId: providerId,
|
||||
providerName: providerName,
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
static Post postFromJson(
|
||||
Map<String, dynamic> json, {
|
||||
required String providerId,
|
||||
required String providerName,
|
||||
}) {
|
||||
final id = _string(json['id']);
|
||||
final fileUrl = _string(json['file_url'] ?? json['fileUrl']);
|
||||
final sampleUrl = _string(json['sample_url'] ??
|
||||
json['sampleUrl'] ??
|
||||
json['large_file_url'] ??
|
||||
fileUrl);
|
||||
final previewUrl =
|
||||
_string(json['preview_url'] ?? json['previewUrl'] ?? sampleUrl);
|
||||
return Post(
|
||||
id: id,
|
||||
providerId: providerId,
|
||||
providerName: providerName,
|
||||
previewUrl: previewUrl,
|
||||
sampleUrl: sampleUrl,
|
||||
fileUrl: fileUrl,
|
||||
tags: _tags(json['tags'] ?? json['tag_string']),
|
||||
rating: _string(json['rating'], fallback: 'unknown'),
|
||||
width: _int(json['width']),
|
||||
height: _int(json['height']),
|
||||
source: _nullableString(json['source']),
|
||||
createdAt: _date(json['created_at'] ?? json['createdAt']),
|
||||
fileType: _fileType(fileUrl),
|
||||
score: _int(json['score']),
|
||||
);
|
||||
}
|
||||
|
||||
static List<dynamic> _extractItems(dynamic data) {
|
||||
if (data is List) return data;
|
||||
if (data is Map) {
|
||||
final posts = data['post'] ?? data['posts'];
|
||||
if (posts is List) return posts;
|
||||
if (posts is Map && posts['post'] is List) return posts['post'] as List;
|
||||
if (posts is Map) return [posts];
|
||||
}
|
||||
return const [];
|
||||
}
|
||||
|
||||
static List<String> _tags(dynamic value) {
|
||||
if (value is List) return value.map((tag) => tag.toString()).toList();
|
||||
return _string(value)
|
||||
.split(RegExp(r'\s+'))
|
||||
.where((tag) => tag.trim().isNotEmpty)
|
||||
.toSet()
|
||||
.toList();
|
||||
}
|
||||
|
||||
static int _int(dynamic value) {
|
||||
if (value is int) return value;
|
||||
if (value is num) return value.toInt();
|
||||
return int.tryParse(value?.toString() ?? '') ?? 0;
|
||||
}
|
||||
|
||||
static String _string(dynamic value, {String fallback = ''}) {
|
||||
final text = value?.toString();
|
||||
return text == null || text.isEmpty ? fallback : text;
|
||||
}
|
||||
|
||||
static String? _nullableString(dynamic value) {
|
||||
final text = value?.toString();
|
||||
return text == null || text.isEmpty ? null : text;
|
||||
}
|
||||
|
||||
static DateTime _date(dynamic value) {
|
||||
if (value is int) return DateTime.fromMillisecondsSinceEpoch(value * 1000);
|
||||
return DateTime.tryParse(value?.toString() ?? '') ?? DateTime.now();
|
||||
}
|
||||
|
||||
static String _fileType(String url) {
|
||||
final lower = url.toLowerCase();
|
||||
if (lower.endsWith('.webm') || lower.endsWith('.mp4')) return 'video';
|
||||
if (lower.endsWith('.gif')) return 'gif';
|
||||
if (lower.isEmpty) return 'unknown';
|
||||
return 'image';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import '../models/post.dart';
|
||||
import 'gelbooru_mapper.dart';
|
||||
|
||||
class Rule34Mapper {
|
||||
static List<Post> postsFromResponse(
|
||||
dynamic data, {
|
||||
required String providerId,
|
||||
required String providerName,
|
||||
}) {
|
||||
return GelbooruMapper.postsFromResponse(
|
||||
data,
|
||||
providerId: providerId,
|
||||
providerName: providerName,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
class Collection {
|
||||
const Collection({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.description,
|
||||
this.coverUrl,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final String? description;
|
||||
final String? coverUrl;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
|
||||
Collection copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
String? description,
|
||||
String? coverUrl,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
}) =>
|
||||
Collection(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
description: description ?? this.description,
|
||||
coverUrl: coverUrl ?? this.coverUrl,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
class ContentProviderConfig {
|
||||
const ContentProviderConfig({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.baseUrl,
|
||||
required this.apiType,
|
||||
required this.enabled,
|
||||
required this.priority,
|
||||
required this.timeoutSeconds,
|
||||
required this.customHeaders,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final String baseUrl;
|
||||
final String apiType;
|
||||
final bool enabled;
|
||||
final int priority;
|
||||
final int timeoutSeconds;
|
||||
final Map<String, String> customHeaders;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
|
||||
ContentProviderConfig copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
String? baseUrl,
|
||||
String? apiType,
|
||||
bool? enabled,
|
||||
int? priority,
|
||||
int? timeoutSeconds,
|
||||
Map<String, String>? customHeaders,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
}) {
|
||||
return ContentProviderConfig(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
baseUrl: baseUrl ?? this.baseUrl,
|
||||
apiType: apiType ?? this.apiType,
|
||||
enabled: enabled ?? this.enabled,
|
||||
priority: priority ?? this.priority,
|
||||
timeoutSeconds: timeoutSeconds ?? this.timeoutSeconds,
|
||||
customHeaders: customHeaders ?? this.customHeaders,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'baseUrl': baseUrl,
|
||||
'apiType': apiType,
|
||||
'enabled': enabled,
|
||||
'priority': priority,
|
||||
'timeoutSeconds': timeoutSeconds,
|
||||
'customHeaders': customHeaders,
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
'updatedAt': updatedAt.toIso8601String(),
|
||||
};
|
||||
|
||||
factory ContentProviderConfig.fromJson(Map<String, dynamic> json) {
|
||||
return ContentProviderConfig(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
baseUrl: json['baseUrl'] as String,
|
||||
apiType: json['apiType'] as String,
|
||||
enabled: (json['enabled'] as bool?) ?? true,
|
||||
priority: (json['priority'] as num?)?.toInt() ?? 0,
|
||||
timeoutSeconds: (json['timeoutSeconds'] as num?)?.toInt() ?? 20,
|
||||
customHeaders: Map<String, String>.from(
|
||||
(json['customHeaders'] as Map?) ?? const {},
|
||||
),
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
updatedAt: DateTime.parse(json['updatedAt'] as String),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
class Favorite {
|
||||
const Favorite({
|
||||
required this.id,
|
||||
required this.postId,
|
||||
required this.providerId,
|
||||
required this.savedAt,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String postId;
|
||||
final String providerId;
|
||||
final DateTime savedAt;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
class Post {
|
||||
const Post({
|
||||
required this.id,
|
||||
required this.providerId,
|
||||
required this.providerName,
|
||||
required this.previewUrl,
|
||||
required this.sampleUrl,
|
||||
required this.fileUrl,
|
||||
required this.tags,
|
||||
required this.rating,
|
||||
required this.width,
|
||||
required this.height,
|
||||
this.source,
|
||||
required this.createdAt,
|
||||
required this.fileType,
|
||||
required this.score,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String providerId;
|
||||
final String providerName;
|
||||
final String previewUrl;
|
||||
final String sampleUrl;
|
||||
final String fileUrl;
|
||||
final List<String> tags;
|
||||
final String rating;
|
||||
final int width;
|
||||
final int height;
|
||||
final String? source;
|
||||
final DateTime createdAt;
|
||||
final String fileType;
|
||||
final int score;
|
||||
|
||||
String get cacheKey => '$providerId:$id';
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'providerId': providerId,
|
||||
'providerName': providerName,
|
||||
'previewUrl': previewUrl,
|
||||
'sampleUrl': sampleUrl,
|
||||
'fileUrl': fileUrl,
|
||||
'tags': tags,
|
||||
'rating': rating,
|
||||
'width': width,
|
||||
'height': height,
|
||||
'source': source,
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
'fileType': fileType,
|
||||
'score': score,
|
||||
};
|
||||
|
||||
factory Post.fromJson(Map<String, dynamic> json) => Post(
|
||||
id: json['id'].toString(),
|
||||
providerId: json['providerId'] as String,
|
||||
providerName: json['providerName'] as String,
|
||||
previewUrl: (json['previewUrl'] as String?) ?? '',
|
||||
sampleUrl: (json['sampleUrl'] as String?) ?? '',
|
||||
fileUrl: (json['fileUrl'] as String?) ?? '',
|
||||
tags: List<String>.from((json['tags'] as List?) ?? const []),
|
||||
rating: (json['rating'] as String?) ?? 'unknown',
|
||||
width: (json['width'] as num?)?.toInt() ?? 0,
|
||||
height: (json['height'] as num?)?.toInt() ?? 0,
|
||||
source: json['source'] as String?,
|
||||
createdAt: DateTime.tryParse((json['createdAt'] as String?) ?? '') ??
|
||||
DateTime.fromMillisecondsSinceEpoch(0),
|
||||
fileType: (json['fileType'] as String?) ?? 'unknown',
|
||||
score: (json['score'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
enum ProviderStatus { unknown, online, offline }
|
||||
|
||||
class ProviderHealth {
|
||||
const ProviderHealth({
|
||||
required this.providerId,
|
||||
required this.status,
|
||||
required this.pingMs,
|
||||
required this.lastCheckedAt,
|
||||
this.errorMessage,
|
||||
this.apiVersion,
|
||||
});
|
||||
|
||||
final String providerId;
|
||||
final ProviderStatus status;
|
||||
final int pingMs;
|
||||
final DateTime lastCheckedAt;
|
||||
final String? errorMessage;
|
||||
final String? apiVersion;
|
||||
|
||||
bool get isOnline => status == ProviderStatus.online;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'providerId': providerId,
|
||||
'status': status.name,
|
||||
'pingMs': pingMs,
|
||||
'lastCheckedAt': lastCheckedAt.toIso8601String(),
|
||||
'errorMessage': errorMessage,
|
||||
'apiVersion': apiVersion,
|
||||
};
|
||||
|
||||
factory ProviderHealth.fromJson(Map<String, dynamic> json) => ProviderHealth(
|
||||
providerId: json['providerId'] as String,
|
||||
status: ProviderStatus.values.firstWhere(
|
||||
(value) => value.name == json['status'],
|
||||
orElse: () => ProviderStatus.unknown,
|
||||
),
|
||||
pingMs: (json['pingMs'] as num?)?.toInt() ?? 0,
|
||||
lastCheckedAt: DateTime.parse(json['lastCheckedAt'] as String),
|
||||
errorMessage: json['errorMessage'] as String?,
|
||||
apiVersion: json['apiVersion'] as String?,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
class SearchHistory {
|
||||
const SearchHistory({
|
||||
required this.id,
|
||||
required this.query,
|
||||
required this.tags,
|
||||
required this.searchedAt,
|
||||
required this.resultCount,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String query;
|
||||
final List<String> tags;
|
||||
final DateTime searchedAt;
|
||||
final int resultCount;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
enum TopPeriodFilter {
|
||||
none,
|
||||
month,
|
||||
year,
|
||||
allTime;
|
||||
|
||||
String get label {
|
||||
return switch (this) {
|
||||
TopPeriodFilter.none => 'Latest',
|
||||
TopPeriodFilter.month => 'Top month',
|
||||
TopPeriodFilter.year => 'Top year',
|
||||
TopPeriodFilter.allTime => 'Top all time',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import '../models/post.dart';
|
||||
import '../models/provider_health.dart';
|
||||
import '../models/top_period_filter.dart';
|
||||
|
||||
abstract class ContentProvider {
|
||||
String get id;
|
||||
String get name;
|
||||
String get baseUrl;
|
||||
|
||||
Future<List<Post>> searchPosts({
|
||||
required List<String> tags,
|
||||
required int page,
|
||||
int limit = 50,
|
||||
String? rating,
|
||||
TopPeriodFilter topPeriod = TopPeriodFilter.none,
|
||||
});
|
||||
|
||||
Future<Post?> getPost(String id);
|
||||
|
||||
Future<ProviderHealth> checkHealth();
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import '../../core/errors/app_exception.dart';
|
||||
import '../models/post.dart';
|
||||
import '../models/provider_health.dart';
|
||||
import '../models/top_period_filter.dart';
|
||||
import 'content_provider.dart';
|
||||
|
||||
class CustomProvider implements ContentProvider {
|
||||
CustomProvider(this._delegate);
|
||||
|
||||
final ContentProvider _delegate;
|
||||
|
||||
@override
|
||||
String get id => _delegate.id;
|
||||
@override
|
||||
String get name => _delegate.name;
|
||||
@override
|
||||
String get baseUrl => _delegate.baseUrl;
|
||||
|
||||
@override
|
||||
Future<List<Post>> searchPosts({
|
||||
required List<String> tags,
|
||||
required int page,
|
||||
int limit = 50,
|
||||
String? rating,
|
||||
TopPeriodFilter topPeriod = TopPeriodFilter.none,
|
||||
}) =>
|
||||
_delegate.searchPosts(
|
||||
tags: tags,
|
||||
page: page,
|
||||
limit: limit,
|
||||
rating: rating,
|
||||
topPeriod: topPeriod,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<Post?> getPost(String id) => _delegate.getPost(id);
|
||||
|
||||
@override
|
||||
Future<ProviderHealth> checkHealth() => _delegate.checkHealth();
|
||||
}
|
||||
|
||||
class UnsupportedCustomProvider implements ContentProvider {
|
||||
UnsupportedCustomProvider({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.baseUrl,
|
||||
required this.apiType,
|
||||
});
|
||||
|
||||
@override
|
||||
final String id;
|
||||
@override
|
||||
final String name;
|
||||
@override
|
||||
final String baseUrl;
|
||||
final String apiType;
|
||||
|
||||
@override
|
||||
Future<List<Post>> searchPosts({
|
||||
required List<String> tags,
|
||||
required int page,
|
||||
int limit = 50,
|
||||
String? rating,
|
||||
TopPeriodFilter topPeriod = TopPeriodFilter.none,
|
||||
}) async {
|
||||
throw ProviderUnavailableException(
|
||||
'Unsupported provider apiType: $apiType');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Post?> getPost(String id) async {
|
||||
throw ProviderUnavailableException(
|
||||
'Unsupported provider apiType: $apiType');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ProviderHealth> checkHealth() async => ProviderHealth(
|
||||
providerId: id,
|
||||
status: ProviderStatus.offline,
|
||||
pingMs: 0,
|
||||
lastCheckedAt: DateTime.now(),
|
||||
errorMessage: 'Unsupported provider apiType: $apiType',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../core/http/dio_client.dart';
|
||||
import '../mappers/danbooru_mapper.dart';
|
||||
import '../models/post.dart';
|
||||
import '../models/provider_health.dart';
|
||||
import '../models/top_period_filter.dart';
|
||||
import 'content_provider.dart';
|
||||
|
||||
class DanbooruProvider implements ContentProvider {
|
||||
DanbooruProvider({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.baseUrl,
|
||||
required DioClient dioClient,
|
||||
Map<String, String> queryParameters = const {},
|
||||
}) : _queryParameters = queryParameters,
|
||||
_dio = dioClient.dio;
|
||||
|
||||
@override
|
||||
final String id;
|
||||
@override
|
||||
final String name;
|
||||
@override
|
||||
final String baseUrl;
|
||||
final Dio _dio;
|
||||
final Map<String, String> _queryParameters;
|
||||
|
||||
@override
|
||||
Future<List<Post>> searchPosts({
|
||||
required List<String> tags,
|
||||
required int page,
|
||||
int limit = 50,
|
||||
String? rating,
|
||||
TopPeriodFilter topPeriod = TopPeriodFilter.none,
|
||||
}) async {
|
||||
final queryTags = [
|
||||
...tags,
|
||||
if (rating != null && rating.isNotEmpty) 'rating:$rating',
|
||||
..._topTags(topPeriod),
|
||||
];
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/posts.json',
|
||||
queryParameters: {
|
||||
'page': page <= 0 ? 1 : page,
|
||||
'limit': limit,
|
||||
'tags': queryTags.join(' '),
|
||||
..._queryParameters,
|
||||
},
|
||||
);
|
||||
return DanbooruMapper.postsFromResponse(
|
||||
response.data,
|
||||
providerId: id,
|
||||
providerName: name,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Post?> getPost(String id) async {
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/posts/$id.json',
|
||||
queryParameters: _queryParameters,
|
||||
);
|
||||
final posts = DanbooruMapper.postsFromResponse(
|
||||
[response.data],
|
||||
providerId: this.id,
|
||||
providerName: name,
|
||||
);
|
||||
return posts.isEmpty ? null : posts.first;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ProviderHealth> checkHealth() async {
|
||||
final startedAt = DateTime.now();
|
||||
try {
|
||||
await _dio.get<dynamic>(
|
||||
'/posts.json',
|
||||
queryParameters: {'limit': 1, ..._queryParameters},
|
||||
);
|
||||
return ProviderHealth(
|
||||
providerId: id,
|
||||
status: ProviderStatus.online,
|
||||
pingMs: DateTime.now().difference(startedAt).inMilliseconds,
|
||||
lastCheckedAt: DateTime.now(),
|
||||
apiVersion: 'danbooru',
|
||||
);
|
||||
} catch (error) {
|
||||
return ProviderHealth(
|
||||
providerId: id,
|
||||
status: ProviderStatus.offline,
|
||||
pingMs: DateTime.now().difference(startedAt).inMilliseconds,
|
||||
lastCheckedAt: DateTime.now(),
|
||||
errorMessage: error.toString(),
|
||||
apiVersion: 'danbooru',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
List<String> _topTags(TopPeriodFilter period) {
|
||||
return switch (period) {
|
||||
TopPeriodFilter.none => const [],
|
||||
TopPeriodFilter.month || TopPeriodFilter.year => const ['order:rank'],
|
||||
TopPeriodFilter.allTime => const ['order:score'],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../core/errors/app_exception.dart';
|
||||
import '../../core/http/dio_client.dart';
|
||||
import '../mappers/gelbooru_mapper.dart';
|
||||
import '../models/post.dart';
|
||||
import '../models/provider_health.dart';
|
||||
import '../models/top_period_filter.dart';
|
||||
import 'content_provider.dart';
|
||||
|
||||
class GelbooruProvider implements ContentProvider {
|
||||
GelbooruProvider({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.baseUrl,
|
||||
required DioClient dioClient,
|
||||
Map<String, String> queryParameters = const {},
|
||||
}) : _queryParameters = queryParameters,
|
||||
_dio = dioClient.dio;
|
||||
|
||||
@override
|
||||
final String id;
|
||||
@override
|
||||
final String name;
|
||||
@override
|
||||
final String baseUrl;
|
||||
final Dio _dio;
|
||||
final Map<String, String> _queryParameters;
|
||||
Dio get dio => _dio;
|
||||
Map<String, String> get queryParameters => _queryParameters;
|
||||
|
||||
@override
|
||||
Future<List<Post>> searchPosts({
|
||||
required List<String> tags,
|
||||
required int page,
|
||||
int limit = 50,
|
||||
String? rating,
|
||||
TopPeriodFilter topPeriod = TopPeriodFilter.none,
|
||||
}) async {
|
||||
final queryTags = [
|
||||
...tags,
|
||||
if (rating != null && rating.isNotEmpty) 'rating:$rating',
|
||||
..._topTags(topPeriod),
|
||||
];
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/index.php',
|
||||
queryParameters: {
|
||||
'page': 'dapi',
|
||||
's': 'post',
|
||||
'q': 'index',
|
||||
'json': '1',
|
||||
'pid': page,
|
||||
'limit': limit,
|
||||
'tags': queryTags.join(' '),
|
||||
..._queryParameters,
|
||||
},
|
||||
);
|
||||
return GelbooruMapper.postsFromResponse(
|
||||
response.data,
|
||||
providerId: id,
|
||||
providerName: name,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Post?> getPost(String id) async {
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/index.php',
|
||||
queryParameters: {
|
||||
'page': 'dapi',
|
||||
's': 'post',
|
||||
'q': 'index',
|
||||
'json': '1',
|
||||
'id': id,
|
||||
..._queryParameters,
|
||||
},
|
||||
);
|
||||
final posts = GelbooruMapper.postsFromResponse(
|
||||
response.data,
|
||||
providerId: this.id,
|
||||
providerName: name,
|
||||
);
|
||||
return posts.isEmpty ? null : posts.first;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ProviderHealth> checkHealth() async {
|
||||
final startedAt = DateTime.now();
|
||||
try {
|
||||
await _dio.get<dynamic>(
|
||||
'/index.php',
|
||||
queryParameters: {
|
||||
'page': 'dapi',
|
||||
's': 'post',
|
||||
'q': 'index',
|
||||
'json': '1',
|
||||
'limit': 1,
|
||||
..._queryParameters,
|
||||
},
|
||||
);
|
||||
return ProviderHealth(
|
||||
providerId: id,
|
||||
status: ProviderStatus.online,
|
||||
pingMs: DateTime.now().difference(startedAt).inMilliseconds,
|
||||
lastCheckedAt: DateTime.now(),
|
||||
apiVersion: 'gelbooru-compatible',
|
||||
);
|
||||
} catch (error) {
|
||||
return ProviderHealth(
|
||||
providerId: id,
|
||||
status: ProviderStatus.offline,
|
||||
pingMs: DateTime.now().difference(startedAt).inMilliseconds,
|
||||
lastCheckedAt: DateTime.now(),
|
||||
errorMessage: error.toString(),
|
||||
apiVersion: 'gelbooru-compatible',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Never unavailable(String message) =>
|
||||
throw ProviderUnavailableException(message);
|
||||
|
||||
List<String> _topTags(TopPeriodFilter period) {
|
||||
return switch (period) {
|
||||
TopPeriodFilter.none => const [],
|
||||
TopPeriodFilter.month ||
|
||||
TopPeriodFilter.year ||
|
||||
TopPeriodFilter.allTime =>
|
||||
const ['sort:score:desc'],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import '../../core/http/dio_client.dart';
|
||||
import '../models/content_provider_config.dart';
|
||||
import 'content_provider.dart';
|
||||
import 'custom_provider.dart';
|
||||
import 'danbooru_provider.dart';
|
||||
import 'gelbooru_provider.dart';
|
||||
import 'rule34_provider.dart';
|
||||
|
||||
class ProviderFactory {
|
||||
ContentProvider create(ContentProviderConfig config) {
|
||||
final headers = Map<String, String>.from(config.customHeaders)
|
||||
..removeWhere((key, _) => key.startsWith('query.'));
|
||||
final queryParameters = {
|
||||
for (final entry in config.customHeaders.entries)
|
||||
if (entry.key.startsWith('query.') && entry.value.trim().isNotEmpty)
|
||||
entry.key.substring('query.'.length): entry.value.trim(),
|
||||
};
|
||||
final client = DioClient(
|
||||
baseUrl: config.baseUrl,
|
||||
timeout: Duration(seconds: config.timeoutSeconds),
|
||||
headers: headers,
|
||||
);
|
||||
return switch (config.apiType.toLowerCase()) {
|
||||
'gelbooru' => GelbooruProvider(
|
||||
id: config.id,
|
||||
name: config.name,
|
||||
baseUrl: config.baseUrl,
|
||||
dioClient: client,
|
||||
queryParameters: queryParameters,
|
||||
),
|
||||
'rule34' => Rule34Provider(
|
||||
id: config.id,
|
||||
name: config.name,
|
||||
baseUrl: config.baseUrl,
|
||||
dioClient: client,
|
||||
queryParameters: queryParameters,
|
||||
),
|
||||
'danbooru' => DanbooruProvider(
|
||||
id: config.id,
|
||||
name: config.name,
|
||||
baseUrl: config.baseUrl,
|
||||
dioClient: client,
|
||||
queryParameters: queryParameters,
|
||||
),
|
||||
_ => UnsupportedCustomProvider(
|
||||
id: config.id,
|
||||
name: config.name,
|
||||
baseUrl: config.baseUrl,
|
||||
apiType: config.apiType,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import 'dart:async';
|
||||
|
||||
import '../../core/errors/failure.dart';
|
||||
import '../../core/utils/result.dart';
|
||||
import '../models/content_provider_config.dart';
|
||||
import '../models/post.dart';
|
||||
import '../models/provider_health.dart';
|
||||
import '../models/top_period_filter.dart';
|
||||
import '../repositories/provider_repository.dart';
|
||||
import 'content_provider.dart';
|
||||
import 'provider_factory.dart';
|
||||
|
||||
class ProviderManager {
|
||||
ProviderManager(this._repository, this._factory);
|
||||
|
||||
final ProviderRepository _repository;
|
||||
final ProviderFactory _factory;
|
||||
|
||||
Future<Result<List<ContentProviderConfig>>> loadConfigs({
|
||||
bool enabledOnly = true,
|
||||
}) async {
|
||||
await _repository.ensureSeedProviders();
|
||||
return _repository.getProviders(enabledOnly: enabledOnly);
|
||||
}
|
||||
|
||||
Future<Result<List<ContentProvider>>> activeProviders() async {
|
||||
final result = await loadConfigs();
|
||||
if (result is Error<List<ContentProviderConfig>>) {
|
||||
return Error(result.failure);
|
||||
}
|
||||
final configs = (result as Success<List<ContentProviderConfig>>).data
|
||||
..sort((a, b) => a.priority.compareTo(b.priority));
|
||||
return Success(configs.map(_factory.create).toList());
|
||||
}
|
||||
|
||||
Future<Result<void>> enableProvider(String id, bool enabled) async {
|
||||
final result = await _repository.getProvider(id);
|
||||
return result.fold(
|
||||
onSuccess: (config) {
|
||||
if (config == null) {
|
||||
return const Error<void>(
|
||||
Failure(code: 'not_found', message: 'Provider not found'),
|
||||
);
|
||||
}
|
||||
return _repository.saveProvider(
|
||||
config.copyWith(enabled: enabled, updatedAt: DateTime.now()),
|
||||
);
|
||||
},
|
||||
onError: Error<void>.new,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Result<void>> addCustomProvider(ContentProviderConfig config) {
|
||||
return _repository.saveProvider(config.copyWith(updatedAt: DateTime.now()));
|
||||
}
|
||||
|
||||
Future<Result<void>> deleteProvider(String id) =>
|
||||
_repository.deleteProvider(id);
|
||||
|
||||
Future<Result<List<ProviderHealth>>> checkAll({int concurrency = 3}) async {
|
||||
final providersResult = await activeProviders();
|
||||
if (providersResult is Error<List<ContentProvider>>) {
|
||||
return Error(providersResult.failure);
|
||||
}
|
||||
final providers = (providersResult as Success<List<ContentProvider>>).data;
|
||||
if (providers.isEmpty) return const Success([]);
|
||||
final results = await _runLimited(
|
||||
providers,
|
||||
concurrency,
|
||||
(provider) async {
|
||||
final health = await provider.checkHealth();
|
||||
await _repository.saveHealth(health);
|
||||
return health;
|
||||
},
|
||||
);
|
||||
return Success(results);
|
||||
}
|
||||
|
||||
Future<Result<List<Post>>> searchAcrossProviders({
|
||||
required List<String> tags,
|
||||
required int page,
|
||||
int limit = 50,
|
||||
String? rating,
|
||||
String? providerId,
|
||||
TopPeriodFilter topPeriod = TopPeriodFilter.none,
|
||||
}) async {
|
||||
final providersResult = await activeProviders();
|
||||
if (providersResult is Error<List<ContentProvider>>) {
|
||||
return Error(providersResult.failure);
|
||||
}
|
||||
var providers = (providersResult as Success<List<ContentProvider>>).data;
|
||||
if (providerId != null) {
|
||||
providers =
|
||||
providers.where((provider) => provider.id == providerId).toList();
|
||||
}
|
||||
|
||||
final posts = <Post>[];
|
||||
for (final provider in providers) {
|
||||
final health = await _repository.getHealth(provider.id);
|
||||
final offline = health is Success<ProviderHealth?> &&
|
||||
health.data?.status == ProviderStatus.offline;
|
||||
if (offline) continue;
|
||||
|
||||
try {
|
||||
final providerPosts = await provider.searchPosts(
|
||||
tags: tags,
|
||||
page: page,
|
||||
limit: limit,
|
||||
rating: rating,
|
||||
topPeriod: topPeriod,
|
||||
);
|
||||
posts.addAll(providerPosts);
|
||||
} catch (_) {
|
||||
await _repository.saveHealth(
|
||||
ProviderHealth(
|
||||
providerId: provider.id,
|
||||
status: ProviderStatus.offline,
|
||||
pingMs: 0,
|
||||
lastCheckedAt: DateTime.now(),
|
||||
errorMessage: 'Search failed',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return Success(posts);
|
||||
}
|
||||
|
||||
Future<Result<Post?>> getPost(String providerId, String postId) async {
|
||||
final providersResult = await activeProviders();
|
||||
if (providersResult is Error<List<ContentProvider>>) {
|
||||
return Error(providersResult.failure);
|
||||
}
|
||||
final providers = (providersResult as Success<List<ContentProvider>>).data;
|
||||
final matches = providers.where((provider) => provider.id == providerId);
|
||||
if (matches.isEmpty) {
|
||||
return const Error(
|
||||
Failure(code: 'not_found', message: 'Provider not found'));
|
||||
}
|
||||
try {
|
||||
return Success(await matches.first.getPost(postId));
|
||||
} catch (error) {
|
||||
return Error(
|
||||
Failure(
|
||||
code: 'provider_get_post',
|
||||
message: 'Could not load post',
|
||||
details: error,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<R>> _runLimited<T, R>(
|
||||
List<T> items,
|
||||
int concurrency,
|
||||
Future<R> Function(T item) action,
|
||||
) async {
|
||||
final results = <R>[];
|
||||
var index = 0;
|
||||
|
||||
Future<void> worker() async {
|
||||
while (index < items.length) {
|
||||
final current = items[index++];
|
||||
results.add(await action(current));
|
||||
}
|
||||
}
|
||||
|
||||
await Future.wait(
|
||||
List.generate(concurrency.clamp(1, items.length), (_) => worker()),
|
||||
);
|
||||
return results;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import '../mappers/rule34_mapper.dart';
|
||||
import '../models/post.dart';
|
||||
import '../models/top_period_filter.dart';
|
||||
import 'gelbooru_provider.dart';
|
||||
|
||||
class Rule34Provider extends GelbooruProvider {
|
||||
Rule34Provider({
|
||||
required super.id,
|
||||
required super.name,
|
||||
required super.baseUrl,
|
||||
required super.dioClient,
|
||||
super.queryParameters,
|
||||
});
|
||||
|
||||
@override
|
||||
Future<List<Post>> searchPosts({
|
||||
required List<String> tags,
|
||||
required int page,
|
||||
int limit = 50,
|
||||
String? rating,
|
||||
TopPeriodFilter topPeriod = TopPeriodFilter.none,
|
||||
}) async {
|
||||
final topTags = switch (topPeriod) {
|
||||
TopPeriodFilter.none => const <String>[],
|
||||
TopPeriodFilter.month ||
|
||||
TopPeriodFilter.year ||
|
||||
TopPeriodFilter.allTime =>
|
||||
const ['sort:score:desc'],
|
||||
};
|
||||
final response = await dio.get<dynamic>(
|
||||
'/index.php',
|
||||
queryParameters: {
|
||||
'page': 'dapi',
|
||||
's': 'post',
|
||||
'q': 'index',
|
||||
'json': '1',
|
||||
'pid': page,
|
||||
'limit': limit,
|
||||
'tags': [
|
||||
...tags,
|
||||
if (rating != null) 'rating:$rating',
|
||||
...topTags,
|
||||
].join(' '),
|
||||
...queryParameters,
|
||||
},
|
||||
);
|
||||
return Rule34Mapper.postsFromResponse(
|
||||
response.data,
|
||||
providerId: id,
|
||||
providerName: name,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import 'package:isar/isar.dart' hide Collection;
|
||||
|
||||
import '../../core/database/app_database.dart';
|
||||
import '../../core/database/database_service.dart';
|
||||
import '../../core/utils/result.dart';
|
||||
import '../models/collection.dart';
|
||||
import '../models/post.dart';
|
||||
import 'post_repository.dart';
|
||||
|
||||
class CollectionRepository {
|
||||
CollectionRepository(this._databaseService, this._postRepository);
|
||||
|
||||
final DatabaseService _databaseService;
|
||||
final PostRepository _postRepository;
|
||||
|
||||
Future<Result<Collection>> save(Collection collection) {
|
||||
return _databaseService.safeWrite((isar) async {
|
||||
await isar.collectionEntitys.put(
|
||||
CollectionEntity()
|
||||
..collectionId = collection.id
|
||||
..name = collection.name
|
||||
..description = collection.description
|
||||
..coverUrl = collection.coverUrl
|
||||
..createdAt = collection.createdAt
|
||||
..updatedAt = collection.updatedAt,
|
||||
);
|
||||
return collection;
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<void>> delete(String id) {
|
||||
return _databaseService.safeWrite((isar) async {
|
||||
await isar.collectionPostEntitys
|
||||
.filter()
|
||||
.collectionIdEqualTo(id)
|
||||
.deleteAll();
|
||||
await isar.collectionEntitys.filter().collectionIdEqualTo(id).deleteAll();
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<List<Collection>>> all() {
|
||||
return _databaseService.safeRead((isar) async {
|
||||
final items = await isar.collectionEntitys.where().findAll();
|
||||
items.sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
|
||||
return items.map((entity) => entity.toModel()).toList();
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<Collection?>> getById(String id) {
|
||||
return _databaseService.safeRead((isar) async {
|
||||
final entity = await isar.collectionEntitys
|
||||
.filter()
|
||||
.collectionIdEqualTo(id)
|
||||
.findFirst();
|
||||
return entity?.toModel();
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<void>> addPost(String collectionId, Post post) async {
|
||||
await _postRepository.cachePosts([post]);
|
||||
return _databaseService.safeWrite((isar) async {
|
||||
await isar.collectionPostEntitys.put(
|
||||
CollectionPostEntity()
|
||||
..linkKey = '$collectionId:${post.providerId}:${post.id}'
|
||||
..collectionId = collectionId
|
||||
..postId = post.id
|
||||
..providerId = post.providerId
|
||||
..addedAt = DateTime.now(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<void>> removePost(
|
||||
String collectionId,
|
||||
String postId,
|
||||
String providerId,
|
||||
) {
|
||||
return _databaseService.safeWrite((isar) async {
|
||||
await isar.collectionPostEntitys
|
||||
.filter()
|
||||
.linkKeyEqualTo('$collectionId:$providerId:$postId')
|
||||
.deleteAll();
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<List<Post>>> posts(String collectionId) {
|
||||
return _databaseService.safeRead((isar) async {
|
||||
final links = await isar.collectionPostEntitys
|
||||
.filter()
|
||||
.collectionIdEqualTo(collectionId)
|
||||
.findAll();
|
||||
links.sort((a, b) => b.addedAt.compareTo(a.addedAt));
|
||||
final posts = <Post>[];
|
||||
for (final link in links) {
|
||||
final entity = await isar.cachedPostEntitys
|
||||
.filter()
|
||||
.providerIdEqualTo(link.providerId)
|
||||
.postIdEqualTo(link.postId)
|
||||
.findFirst();
|
||||
if (entity != null) posts.add(entity.toModel());
|
||||
}
|
||||
return posts;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
import '../../core/database/app_database.dart';
|
||||
import '../../core/database/database_service.dart';
|
||||
import '../../core/utils/result.dart';
|
||||
import '../models/favorite.dart';
|
||||
import '../models/post.dart';
|
||||
import 'post_repository.dart';
|
||||
|
||||
class FavoriteRepository {
|
||||
FavoriteRepository(this._databaseService, this._postRepository);
|
||||
|
||||
final DatabaseService _databaseService;
|
||||
final PostRepository _postRepository;
|
||||
|
||||
Future<Result<void>> add(Post post) async {
|
||||
await _postRepository.cachePosts([post]);
|
||||
return _databaseService.safeWrite((isar) async {
|
||||
final key = '${post.providerId}:${post.id}';
|
||||
await isar.favoriteEntitys.put(
|
||||
FavoriteEntity()
|
||||
..favoriteKey = key
|
||||
..favoriteId = key
|
||||
..postId = post.id
|
||||
..providerId = post.providerId
|
||||
..savedAt = DateTime.now(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<void>> remove(String postId, String providerId) {
|
||||
return _databaseService.safeWrite((isar) async {
|
||||
await isar.favoriteEntitys
|
||||
.filter()
|
||||
.favoriteKeyEqualTo('$providerId:$postId')
|
||||
.deleteAll();
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<bool>> exists(String postId, String providerId) {
|
||||
return _databaseService.safeRead((isar) async {
|
||||
return await isar.favoriteEntitys
|
||||
.filter()
|
||||
.favoriteKeyEqualTo('$providerId:$postId')
|
||||
.count() >
|
||||
0;
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<List<Favorite>>> all() {
|
||||
return _databaseService.safeRead((isar) async {
|
||||
final items = await isar.favoriteEntitys.where().findAll();
|
||||
items.sort((a, b) => b.savedAt.compareTo(a.savedAt));
|
||||
return items.map((entity) => entity.toModel()).toList();
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<void>> clear() {
|
||||
return _databaseService.safeWrite((isar) async {
|
||||
await isar.favoriteEntitys.clear();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
import '../../core/database/app_database.dart';
|
||||
import '../../core/database/database_service.dart';
|
||||
import '../../core/utils/result.dart';
|
||||
import '../models/post.dart';
|
||||
|
||||
class PostRepository {
|
||||
PostRepository(this._databaseService);
|
||||
|
||||
final DatabaseService _databaseService;
|
||||
|
||||
Future<Result<void>> cachePosts(List<Post> posts) {
|
||||
return _databaseService.safeWrite((isar) async {
|
||||
await isar.cachedPostEntitys.putAll(
|
||||
posts.map((post) => CachedPostEntity.fromModel(post)).toList(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<Post?>> getCachedPost(String postId, String providerId) {
|
||||
return _databaseService.safeRead((isar) async {
|
||||
final entity = await isar.cachedPostEntitys
|
||||
.filter()
|
||||
.providerIdEqualTo(providerId)
|
||||
.postIdEqualTo(postId)
|
||||
.findFirst();
|
||||
return entity?.toModel();
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<List<Post>>> getCachedPostsByKeys(Iterable<String> keys) {
|
||||
return _databaseService.safeRead((isar) async {
|
||||
final posts = <Post>[];
|
||||
for (final key in keys) {
|
||||
final entity = await isar.cachedPostEntitys
|
||||
.filter()
|
||||
.cacheKeyEqualTo(key)
|
||||
.findFirst();
|
||||
if (entity != null) posts.add(entity.toModel());
|
||||
}
|
||||
return posts;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
import '../../core/database/app_database.dart';
|
||||
import '../../core/database/database_service.dart';
|
||||
import '../../core/utils/result.dart';
|
||||
import '../models/content_provider_config.dart';
|
||||
import '../models/provider_health.dart';
|
||||
|
||||
class ProviderRepository {
|
||||
ProviderRepository(this._databaseService);
|
||||
|
||||
final DatabaseService _databaseService;
|
||||
|
||||
static List<ContentProviderConfig> seedProviders() {
|
||||
final now = DateTime.now();
|
||||
return [
|
||||
ContentProviderConfig(
|
||||
id: 'gelbooru',
|
||||
name: 'Gelbooru',
|
||||
baseUrl: 'https://gelbooru.com',
|
||||
apiType: 'gelbooru',
|
||||
enabled: true,
|
||||
priority: 0,
|
||||
timeoutSeconds: 20,
|
||||
customHeaders: const {},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
),
|
||||
ContentProviderConfig(
|
||||
id: 'rule34',
|
||||
name: 'Rule34',
|
||||
baseUrl: 'https://api.rule34.xxx',
|
||||
apiType: 'rule34',
|
||||
enabled: true,
|
||||
priority: 1,
|
||||
timeoutSeconds: 20,
|
||||
customHeaders: const {},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
),
|
||||
ContentProviderConfig(
|
||||
id: 'safebooru',
|
||||
name: 'Safebooru',
|
||||
baseUrl: 'https://safebooru.org',
|
||||
apiType: 'gelbooru',
|
||||
enabled: true,
|
||||
priority: 2,
|
||||
timeoutSeconds: 20,
|
||||
customHeaders: const {},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
Future<Result<void>> ensureSeedProviders() {
|
||||
return _databaseService.safeWrite((isar) async {
|
||||
final count = await isar.providerConfigEntitys.count();
|
||||
if (count > 0) return;
|
||||
await isar.providerConfigEntitys.putAll(
|
||||
seedProviders().map(ProviderConfigEntity.fromModel).toList(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<List<ContentProviderConfig>>> getProviders({
|
||||
bool enabledOnly = false,
|
||||
}) {
|
||||
return _databaseService.safeRead((isar) async {
|
||||
final providers = enabledOnly
|
||||
? await isar.providerConfigEntitys
|
||||
.filter()
|
||||
.enabledEqualTo(true)
|
||||
.findAll()
|
||||
: await isar.providerConfigEntitys.where().findAll();
|
||||
providers.sort((a, b) => a.priority.compareTo(b.priority));
|
||||
return providers.map((entity) => entity.toModel()).toList();
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<ContentProviderConfig?>> getProvider(String id) {
|
||||
return _databaseService.safeRead((isar) async {
|
||||
final entity = await isar.providerConfigEntitys
|
||||
.filter()
|
||||
.providerIdEqualTo(id)
|
||||
.findFirst();
|
||||
return entity?.toModel();
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<void>> saveProvider(ContentProviderConfig config) {
|
||||
return _databaseService.safeWrite((isar) async {
|
||||
await isar.providerConfigEntitys
|
||||
.put(ProviderConfigEntity.fromModel(config));
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<void>> deleteProvider(String id) {
|
||||
return _databaseService.safeWrite((isar) async {
|
||||
await isar.providerConfigEntitys
|
||||
.filter()
|
||||
.providerIdEqualTo(id)
|
||||
.deleteAll();
|
||||
await isar.providerHealthEntitys
|
||||
.filter()
|
||||
.providerIdEqualTo(id)
|
||||
.deleteAll();
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<void>> saveHealth(ProviderHealth health) {
|
||||
return _databaseService.safeWrite((isar) async {
|
||||
await isar.providerHealthEntitys
|
||||
.put(ProviderHealthEntity.fromModel(health));
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<ProviderHealth?>> getHealth(String providerId) {
|
||||
return _databaseService.safeRead((isar) async {
|
||||
final entity = await isar.providerHealthEntitys
|
||||
.filter()
|
||||
.providerIdEqualTo(providerId)
|
||||
.findFirst();
|
||||
return entity?.toModel();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
import '../../core/database/app_database.dart';
|
||||
import '../../core/database/database_service.dart';
|
||||
import '../../core/utils/result.dart';
|
||||
import '../models/search_history.dart';
|
||||
|
||||
class SearchRepository {
|
||||
SearchRepository(this._databaseService);
|
||||
|
||||
final DatabaseService _databaseService;
|
||||
|
||||
Future<Result<void>> save(SearchHistory history) {
|
||||
return _databaseService.safeWrite((isar) async {
|
||||
await isar.searchHistoryEntitys.put(
|
||||
SearchHistoryEntity()
|
||||
..historyId = history.id
|
||||
..query = history.query
|
||||
..tags = history.tags
|
||||
..searchedAt = history.searchedAt
|
||||
..resultCount = history.resultCount,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<List<SearchHistory>>> recent({int limit = 20}) {
|
||||
return _databaseService.safeRead((isar) async {
|
||||
final items = await isar.searchHistoryEntitys.where().findAll();
|
||||
items.sort((a, b) => b.searchedAt.compareTo(a.searchedAt));
|
||||
return items.take(limit).map((entity) => entity.toModel()).toList();
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<void>> clear() {
|
||||
return _databaseService.safeWrite((isar) async {
|
||||
await isar.searchHistoryEntitys.clear();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import '../../core/utils/result.dart';
|
||||
import '../../core/errors/failure.dart';
|
||||
import '../models/collection.dart';
|
||||
import '../models/post.dart';
|
||||
import '../repositories/collection_repository.dart';
|
||||
|
||||
class CollectionService {
|
||||
CollectionService(this._repository);
|
||||
|
||||
final CollectionRepository _repository;
|
||||
|
||||
Future<Result<Collection>> createCollection(
|
||||
String name, String? description) {
|
||||
final now = DateTime.now();
|
||||
return _repository.save(
|
||||
Collection(
|
||||
id: now.microsecondsSinceEpoch.toString(),
|
||||
name: name,
|
||||
description: description,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Result<Collection>> updateCollection(
|
||||
String id, {
|
||||
required String name,
|
||||
String? description,
|
||||
String? coverUrl,
|
||||
}) async {
|
||||
final existingResult = await _repository.getById(id);
|
||||
if (existingResult is Error<Collection?>) {
|
||||
return Error(existingResult.failure);
|
||||
}
|
||||
final existing = (existingResult as Success<Collection?>).data;
|
||||
if (existing == null) {
|
||||
return const Error(
|
||||
Failure(code: 'not_found', message: 'Collection not found'),
|
||||
);
|
||||
}
|
||||
return _repository.save(
|
||||
Collection(
|
||||
id: id,
|
||||
name: name,
|
||||
description: description,
|
||||
coverUrl: coverUrl,
|
||||
createdAt: existing.createdAt,
|
||||
updatedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Result<void>> deleteCollection(String id) => _repository.delete(id);
|
||||
Future<Result<void>> addPostToCollection(String collectionId, Post post) {
|
||||
return _repository.addPost(collectionId, post);
|
||||
}
|
||||
|
||||
Future<Result<void>> removePostFromCollection(
|
||||
String collectionId,
|
||||
String postId,
|
||||
String providerId,
|
||||
) {
|
||||
return _repository.removePost(collectionId, postId, providerId);
|
||||
}
|
||||
|
||||
Future<Result<List<Collection>>> getCollections() => _repository.all();
|
||||
Future<Result<List<Post>>> getCollectionPosts(String collectionId) {
|
||||
return _repository.posts(collectionId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import '../../core/utils/result.dart';
|
||||
import '../models/favorite.dart';
|
||||
import '../models/post.dart';
|
||||
import '../repositories/favorite_repository.dart';
|
||||
import '../repositories/post_repository.dart';
|
||||
|
||||
class FavoriteService {
|
||||
FavoriteService(this._repository, [this._postRepository]);
|
||||
|
||||
final FavoriteRepository _repository;
|
||||
final PostRepository? _postRepository;
|
||||
|
||||
Future<Result<void>> addFavorite(Post post) => _repository.add(post);
|
||||
Future<Result<void>> removeFavorite(String postId, String providerId) {
|
||||
return _repository.remove(postId, providerId);
|
||||
}
|
||||
|
||||
Future<Result<bool>> isFavorite(String postId, String providerId) {
|
||||
return _repository.exists(postId, providerId);
|
||||
}
|
||||
|
||||
Future<Result<List<Favorite>>> getFavorites() => _repository.all();
|
||||
Future<Result<List<Post>>> getFavoritePosts() async {
|
||||
final postRepository = _postRepository;
|
||||
if (postRepository == null) return const Success([]);
|
||||
final favoritesResult = await _repository.all();
|
||||
if (favoritesResult is Error<List<Favorite>>) {
|
||||
return Error(favoritesResult.failure);
|
||||
}
|
||||
final favorites = (favoritesResult as Success<List<Favorite>>).data;
|
||||
return postRepository.getCachedPostsByKeys(
|
||||
favorites.map((favorite) => '${favorite.providerId}:${favorite.postId}'),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Result<void>> clearFavorites() => _repository.clear();
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import '../../core/cache/cache_service.dart';
|
||||
import '../../core/utils/result.dart';
|
||||
import '../models/post.dart';
|
||||
import '../models/top_period_filter.dart';
|
||||
import '../providers/provider_manager.dart';
|
||||
import 'settings_service.dart';
|
||||
|
||||
class FeedService {
|
||||
FeedService(this._providerManager, this._cacheService, this._settingsService);
|
||||
|
||||
final ProviderManager _providerManager;
|
||||
final CacheService _cacheService;
|
||||
final SettingsService _settingsService;
|
||||
final Map<String, int> _pages = {};
|
||||
|
||||
String _key(
|
||||
List<String> tags,
|
||||
String? rating,
|
||||
String? providerId,
|
||||
TopPeriodFilter topPeriod,
|
||||
) {
|
||||
final sorted = [...tags]..sort();
|
||||
return '${sorted.join(' ')}|${rating ?? ''}|${providerId ?? ''}|${topPeriod.name}';
|
||||
}
|
||||
|
||||
Future<Result<List<Post>>> refresh({
|
||||
List<String> tags = const [],
|
||||
String? rating,
|
||||
String? providerId,
|
||||
TopPeriodFilter topPeriod = TopPeriodFilter.none,
|
||||
int limit = 50,
|
||||
}) {
|
||||
_pages[_key(tags, rating, providerId, topPeriod)] = 0;
|
||||
return loadNextPage(
|
||||
tags: tags,
|
||||
rating: rating,
|
||||
providerId: providerId,
|
||||
topPeriod: topPeriod,
|
||||
limit: limit,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Result<List<Post>>> loadNextPage({
|
||||
List<String> tags = const [],
|
||||
String? rating,
|
||||
String? providerId,
|
||||
TopPeriodFilter topPeriod = TopPeriodFilter.none,
|
||||
int limit = 50,
|
||||
}) async {
|
||||
final key = _key(tags, rating, providerId, topPeriod);
|
||||
final page = _pages[key] ?? 0;
|
||||
final result = await _providerManager.searchAcrossProviders(
|
||||
tags: tags,
|
||||
page: page,
|
||||
limit: limit,
|
||||
rating: rating,
|
||||
providerId: providerId,
|
||||
topPeriod: topPeriod,
|
||||
);
|
||||
if (result is Error<List<Post>>) return Error(result.failure);
|
||||
final posts = (result as Success<List<Post>>).data;
|
||||
_pages[key] = page + 1;
|
||||
final settingsResult = await _settingsService.getSettings();
|
||||
final maxItems = settingsResult is Success<AppSettings>
|
||||
? settingsResult.data.cacheMaxItems
|
||||
: null;
|
||||
await _cacheService.cachePosts(posts, maxItems: maxItems);
|
||||
return Success(posts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import '../../core/errors/failure.dart';
|
||||
import '../../core/utils/result.dart';
|
||||
import '../models/content_provider_config.dart';
|
||||
import '../models/provider_health.dart';
|
||||
import '../providers/provider_factory.dart';
|
||||
import '../providers/provider_manager.dart';
|
||||
import '../repositories/provider_repository.dart';
|
||||
|
||||
class ProviderCheckService {
|
||||
ProviderCheckService(this._repository, this._factory, this._manager);
|
||||
|
||||
final ProviderRepository _repository;
|
||||
final ProviderFactory _factory;
|
||||
final ProviderManager _manager;
|
||||
|
||||
Future<Result<ProviderHealth>> checkOne(String providerId) async {
|
||||
final configResult = await _repository.getProvider(providerId);
|
||||
if (configResult is Error<ContentProviderConfig?>) {
|
||||
return Error(configResult.failure);
|
||||
}
|
||||
final config = (configResult as Success<ContentProviderConfig?>).data;
|
||||
if (config == null) {
|
||||
return const Error(
|
||||
Failure(code: 'not_found', message: 'Provider not found'),
|
||||
);
|
||||
}
|
||||
final health = await _factory.create(config).checkHealth();
|
||||
await _repository.saveHealth(health);
|
||||
return Success(health);
|
||||
}
|
||||
|
||||
Future<Result<List<ProviderHealth>>> checkAll() {
|
||||
return _manager.checkAll(concurrency: 3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import '../../core/utils/result.dart';
|
||||
import '../models/search_history.dart';
|
||||
import '../repositories/search_repository.dart';
|
||||
|
||||
class SearchService {
|
||||
SearchService(this._repository);
|
||||
|
||||
final SearchRepository _repository;
|
||||
|
||||
List<String> parseTags(String query) {
|
||||
return query
|
||||
.trim()
|
||||
.split(RegExp(r'\s+'))
|
||||
.where((tag) => tag.isNotEmpty)
|
||||
.toSet()
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<Result<void>> saveSearch(String query, int resultCount) {
|
||||
final tags = parseTags(query);
|
||||
return _repository.save(
|
||||
SearchHistory(
|
||||
id: '${DateTime.now().microsecondsSinceEpoch}:${tags.join(',')}',
|
||||
query: query.trim(),
|
||||
tags: tags,
|
||||
searchedAt: DateTime.now(),
|
||||
resultCount: resultCount,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Result<List<SearchHistory>>> recentSearches({int limit = 20}) {
|
||||
return _repository.recent(limit: limit);
|
||||
}
|
||||
|
||||
Future<Result<List<String>>> autocomplete(String prefix,
|
||||
{int limit = 10}) async {
|
||||
final recent = await _repository.recent(limit: 100);
|
||||
return recent.fold(
|
||||
onSuccess: (items) {
|
||||
final needle = prefix.toLowerCase();
|
||||
final values = <String>{};
|
||||
for (final item in items) {
|
||||
for (final tag in item.tags) {
|
||||
if (tag.toLowerCase().startsWith(needle)) values.add(tag);
|
||||
}
|
||||
}
|
||||
return Success(values.take(limit).toList());
|
||||
},
|
||||
onError: Error<List<String>>.new,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Result<void>> clearHistory() => _repository.clear();
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
import '../../core/database/app_database.dart';
|
||||
import '../../core/database/database_service.dart';
|
||||
import '../../core/errors/failure.dart';
|
||||
import '../../core/utils/result.dart';
|
||||
import '../models/content_provider_config.dart';
|
||||
import '../repositories/provider_repository.dart';
|
||||
|
||||
class AppSettings {
|
||||
const AppSettings({
|
||||
required this.enabledProviderIds,
|
||||
required this.nsfwEnabled,
|
||||
required this.cacheTtlHours,
|
||||
required this.cacheMaxItems,
|
||||
required this.providerPriority,
|
||||
required this.themeMode,
|
||||
required this.desktopColumns,
|
||||
required this.mobileColumns,
|
||||
required this.blurExplicitContent,
|
||||
required this.allowDownloads,
|
||||
required this.selectedFeedProviderIds,
|
||||
required this.showPostBadges,
|
||||
required this.defaultTopPeriodFilter,
|
||||
this.defaultRatingFilter,
|
||||
});
|
||||
|
||||
final List<String> enabledProviderIds;
|
||||
final bool nsfwEnabled;
|
||||
final int cacheTtlHours;
|
||||
final int cacheMaxItems;
|
||||
final Map<String, int> providerPriority;
|
||||
final String themeMode;
|
||||
final int desktopColumns;
|
||||
final int mobileColumns;
|
||||
final bool blurExplicitContent;
|
||||
final bool allowDownloads;
|
||||
final List<String> selectedFeedProviderIds;
|
||||
final bool showPostBadges;
|
||||
final String defaultTopPeriodFilter;
|
||||
final String? defaultRatingFilter;
|
||||
|
||||
static const defaults = AppSettings(
|
||||
enabledProviderIds: ['gelbooru', 'rule34', 'safebooru'],
|
||||
nsfwEnabled: true,
|
||||
cacheTtlHours: 24,
|
||||
cacheMaxItems: 2000,
|
||||
providerPriority: {'gelbooru': 0, 'rule34': 1, 'safebooru': 2},
|
||||
themeMode: 'dark',
|
||||
desktopColumns: 5,
|
||||
mobileColumns: 2,
|
||||
blurExplicitContent: true,
|
||||
allowDownloads: true,
|
||||
selectedFeedProviderIds: [],
|
||||
showPostBadges: true,
|
||||
defaultTopPeriodFilter: 'none',
|
||||
defaultRatingFilter: null,
|
||||
);
|
||||
|
||||
AppSettings copyWith({
|
||||
List<String>? enabledProviderIds,
|
||||
bool? nsfwEnabled,
|
||||
int? cacheTtlHours,
|
||||
int? cacheMaxItems,
|
||||
Map<String, int>? providerPriority,
|
||||
String? themeMode,
|
||||
int? desktopColumns,
|
||||
int? mobileColumns,
|
||||
bool? blurExplicitContent,
|
||||
bool? allowDownloads,
|
||||
List<String>? selectedFeedProviderIds,
|
||||
bool? showPostBadges,
|
||||
String? defaultTopPeriodFilter,
|
||||
String? defaultRatingFilter,
|
||||
}) {
|
||||
return AppSettings(
|
||||
enabledProviderIds: enabledProviderIds ?? this.enabledProviderIds,
|
||||
nsfwEnabled: nsfwEnabled ?? this.nsfwEnabled,
|
||||
cacheTtlHours: cacheTtlHours ?? this.cacheTtlHours,
|
||||
cacheMaxItems: cacheMaxItems ?? this.cacheMaxItems,
|
||||
providerPriority: providerPriority ?? this.providerPriority,
|
||||
themeMode: themeMode ?? this.themeMode,
|
||||
desktopColumns: desktopColumns ?? this.desktopColumns,
|
||||
mobileColumns: mobileColumns ?? this.mobileColumns,
|
||||
blurExplicitContent: blurExplicitContent ?? this.blurExplicitContent,
|
||||
allowDownloads: allowDownloads ?? this.allowDownloads,
|
||||
selectedFeedProviderIds:
|
||||
selectedFeedProviderIds ?? this.selectedFeedProviderIds,
|
||||
showPostBadges: showPostBadges ?? this.showPostBadges,
|
||||
defaultTopPeriodFilter:
|
||||
defaultTopPeriodFilter ?? this.defaultTopPeriodFilter,
|
||||
defaultRatingFilter: defaultRatingFilter ?? this.defaultRatingFilter,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'enabledProviderIds': enabledProviderIds,
|
||||
'nsfwEnabled': nsfwEnabled,
|
||||
'cacheTtlHours': cacheTtlHours,
|
||||
'cacheMaxItems': cacheMaxItems,
|
||||
'providerPriority': providerPriority,
|
||||
'themeMode': themeMode,
|
||||
'desktopColumns': desktopColumns,
|
||||
'mobileColumns': mobileColumns,
|
||||
'blurExplicitContent': blurExplicitContent,
|
||||
'allowDownloads': allowDownloads,
|
||||
'selectedFeedProviderIds': selectedFeedProviderIds,
|
||||
'showPostBadges': showPostBadges,
|
||||
'defaultTopPeriodFilter': defaultTopPeriodFilter,
|
||||
'defaultRatingFilter': defaultRatingFilter,
|
||||
};
|
||||
|
||||
factory AppSettings.fromJson(Map<String, dynamic> json) => AppSettings(
|
||||
enabledProviderIds: List<String>.from(
|
||||
(json['enabledProviderIds'] as List?) ?? defaults.enabledProviderIds,
|
||||
),
|
||||
nsfwEnabled: (json['nsfwEnabled'] as bool?) ?? defaults.nsfwEnabled,
|
||||
cacheTtlHours:
|
||||
(json['cacheTtlHours'] as num?)?.toInt() ?? defaults.cacheTtlHours,
|
||||
cacheMaxItems:
|
||||
(json['cacheMaxItems'] as num?)?.toInt() ?? defaults.cacheMaxItems,
|
||||
providerPriority: Map<String, int>.from(
|
||||
(json['providerPriority'] as Map?) ?? defaults.providerPriority,
|
||||
),
|
||||
themeMode: (json['themeMode'] as String?) ?? defaults.themeMode,
|
||||
desktopColumns: (json['desktopColumns'] as num?)?.toInt() ??
|
||||
defaults.desktopColumns,
|
||||
mobileColumns:
|
||||
(json['mobileColumns'] as num?)?.toInt() ?? defaults.mobileColumns,
|
||||
blurExplicitContent: (json['blurExplicitContent'] as bool?) ??
|
||||
defaults.blurExplicitContent,
|
||||
allowDownloads:
|
||||
(json['allowDownloads'] as bool?) ?? defaults.allowDownloads,
|
||||
selectedFeedProviderIds: List<String>.from(
|
||||
(json['selectedFeedProviderIds'] as List?) ??
|
||||
defaults.selectedFeedProviderIds,
|
||||
),
|
||||
showPostBadges:
|
||||
(json['showPostBadges'] as bool?) ?? defaults.showPostBadges,
|
||||
defaultTopPeriodFilter: (json['defaultTopPeriodFilter'] as String?) ??
|
||||
defaults.defaultTopPeriodFilter,
|
||||
defaultRatingFilter: json['defaultRatingFilter'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
class SettingsService {
|
||||
SettingsService(
|
||||
this._databaseService, {
|
||||
ProviderRepository? providerRepository,
|
||||
}) : _providerRepository = providerRepository;
|
||||
|
||||
final DatabaseService _databaseService;
|
||||
final ProviderRepository? _providerRepository;
|
||||
static const _settingsKey = 'app_settings';
|
||||
|
||||
Future<Result<AppSettings>> getSettings() {
|
||||
return _databaseService.safeRead((isar) async {
|
||||
final entity = await isar.appSettingEntitys
|
||||
.filter()
|
||||
.keyEqualTo(_settingsKey)
|
||||
.findFirst();
|
||||
if (entity == null) return AppSettings.defaults;
|
||||
return AppSettings.fromJson(
|
||||
jsonDecode(entity.jsonValue) as Map<String, dynamic>);
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<void>> updateSettings(AppSettings settings) {
|
||||
return _databaseService.safeWrite((isar) async {
|
||||
await isar.appSettingEntitys.put(
|
||||
AppSettingEntity()
|
||||
..key = _settingsKey
|
||||
..jsonValue = jsonEncode(settings.toJson())
|
||||
..updatedAt = DateTime.now(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<void>> saveEnabledProviders(List<String> providerIds) async {
|
||||
final result = await getSettings();
|
||||
if (result is Error<AppSettings>) return Error(result.failure);
|
||||
final settings = (result as Success<AppSettings>).data;
|
||||
final updateResult = await updateSettings(
|
||||
settings.copyWith(enabledProviderIds: providerIds));
|
||||
if (updateResult is Error<void>) return updateResult;
|
||||
|
||||
final repository = _providerRepository;
|
||||
if (repository == null) return updateResult;
|
||||
|
||||
final providersResult = await repository.getProviders();
|
||||
if (providersResult is Error<List<ContentProviderConfig>>) {
|
||||
return Error(providersResult.failure);
|
||||
}
|
||||
final enabled = providerIds.toSet();
|
||||
for (final provider
|
||||
in (providersResult as Success<List<ContentProviderConfig>>).data) {
|
||||
await repository.saveProvider(
|
||||
provider.copyWith(
|
||||
enabled: enabled.contains(provider.id),
|
||||
updatedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
}
|
||||
return updateResult;
|
||||
}
|
||||
|
||||
Future<Result<void>> saveNsfwFilter(bool enabled) async {
|
||||
final result = await getSettings();
|
||||
if (result is Error<AppSettings>) return Error(result.failure);
|
||||
final settings = (result as Success<AppSettings>).data;
|
||||
return updateSettings(settings.copyWith(nsfwEnabled: enabled));
|
||||
}
|
||||
|
||||
Future<Result<void>> saveCacheSettings({
|
||||
required int ttlHours,
|
||||
required int maxItems,
|
||||
}) async {
|
||||
final result = await getSettings();
|
||||
if (result is Error<AppSettings>) return Error(result.failure);
|
||||
final settings = (result as Success<AppSettings>).data;
|
||||
return updateSettings(
|
||||
settings.copyWith(cacheTtlHours: ttlHours, cacheMaxItems: maxItems),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Result<String>> exportSettingsToJson() async {
|
||||
final result = await getSettings();
|
||||
return result.fold(
|
||||
onSuccess: (settings) => Success(jsonEncode(settings.toJson())),
|
||||
onError: Error<String>.new,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Result<void>> importSettingsFromJson(String json) async {
|
||||
try {
|
||||
final settings =
|
||||
AppSettings.fromJson(jsonDecode(json) as Map<String, dynamic>);
|
||||
final result = await updateSettings(settings);
|
||||
if (result is Error<void>) return result;
|
||||
await saveEnabledProviders(settings.enabledProviderIds);
|
||||
return result;
|
||||
} catch (error) {
|
||||
return Error(
|
||||
Failure(
|
||||
code: 'settings_import',
|
||||
message: 'Invalid settings JSON',
|
||||
details: error,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user