Add QoL feed, download and quality improvements

This commit is contained in:
2026-05-24 13:02:06 +03:00
parent 7f79addb14
commit ec29ed21d2
25 changed files with 942 additions and 62 deletions
@@ -56,6 +56,13 @@ class CollectionService {
return _repository.addPost(collectionId, post);
}
Future<Result<void>> addPostsToCollection(
String collectionId,
List<Post> posts,
) {
return _repository.addPosts(collectionId, posts);
}
Future<Result<void>> removePostFromCollection(
String collectionId,
String postId,
@@ -0,0 +1,114 @@
import 'dart:async';
import '../models/download_task.dart';
import '../models/post.dart';
import '../utils/media_quality.dart';
import 'download_service.dart';
class DownloadManagerService {
DownloadManagerService(this._downloadService);
final DownloadService _downloadService;
final _controller = StreamController<List<DownloadTask>>.broadcast();
final Map<String, DownloadTask> _tasks = {};
Stream<List<DownloadTask>> get stream => _controller.stream;
List<DownloadTask> get tasks => List.unmodifiable(_tasks.values);
Future<DownloadTask> start(Post post) async {
final id = '${post.cacheKey}:${DateTime.now().microsecondsSinceEpoch}';
final task = DownloadTask(
id: id,
post: post,
fileName: _suggestedFileName(post),
progress: 0,
status: DownloadTaskStatus.queued,
);
_set(task);
unawaited(_run(task));
return task;
}
Future<void> retry(String taskId) async {
final existing = _tasks[taskId];
if (existing == null) return;
final task = existing.copyWith(
progress: 0,
status: DownloadTaskStatus.queued,
error: null,
);
_set(task);
await _run(task);
}
void cancel(String taskId) {
final existing = _tasks[taskId];
if (existing == null) return;
_set(existing.copyWith(status: DownloadTaskStatus.canceled));
}
Future<void> _run(DownloadTask task) async {
if (_tasks[task.id]?.status == DownloadTaskStatus.canceled) return;
_set(task.copyWith(status: DownloadTaskStatus.running));
try {
final saved = await _downloadService.downloadPost(
task.post,
onProgress: (received, total) {
if (total <= 0) return;
final current = _tasks[task.id];
if (current == null ||
current.status == DownloadTaskStatus.canceled) {
return;
}
_set(current.copyWith(progress: received / total));
},
);
final current = _tasks[task.id];
if (current == null || current.status == DownloadTaskStatus.canceled) {
return;
}
_set(
current.copyWith(
progress: 1,
status: DownloadTaskStatus.completed,
savedPath: saved,
),
);
_scheduleAutoRemove(task.id, const Duration(seconds: 6));
} catch (error) {
final current = _tasks[task.id];
if (current == null || current.status == DownloadTaskStatus.canceled) {
return;
}
_set(
current.copyWith(
status: DownloadTaskStatus.failed,
error: error.toString(),
),
);
}
}
void _set(DownloadTask task) {
_tasks[task.id] = task;
_controller.add(tasks);
}
void _scheduleAutoRemove(String taskId, Duration delay) {
Future<void>.delayed(delay, () {
final current = _tasks[taskId];
if (current?.status != DownloadTaskStatus.completed) return;
_tasks.remove(taskId);
_controller.add(tasks);
});
}
String _suggestedFileName(Post post) {
final url = MediaUrlSelector.download(post) ?? post.fileUrl;
final parsed = Uri.tryParse(url);
final last =
parsed?.pathSegments.isEmpty ?? true ? '' : parsed!.pathSegments.last;
if (last.contains('.')) return last;
return '${post.providerId}_${post.id}';
}
}
+2 -4
View File
@@ -7,6 +7,7 @@ import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import '../models/post.dart';
import '../utils/media_quality.dart';
class DownloadService {
DownloadService({Dio? dio}) : _dio = dio ?? Dio();
@@ -43,10 +44,7 @@ class DownloadService {
}
String? _downloadUrl(Post post) {
for (final url in [post.sampleUrl, post.fileUrl, post.previewUrl]) {
if (url.trim().isNotEmpty) return url;
}
return null;
return MediaUrlSelector.download(post);
}
String _fileName(Post post, String url) {
+20 -1
View File
@@ -78,7 +78,7 @@ class FeedService {
final viewedKeys = viewedKeysResult is Success<Set<String>>
? viewedKeysResult.data
: <String>{};
final filteredPosts = posts
final filteredPosts = _applyTopPeriod(posts, topPeriod)
.where((post) => postMatchesRequestedTags(post, tags))
.where((post) => postPassesTagFilters(post, settings))
.where((post) =>
@@ -90,6 +90,25 @@ class FeedService {
);
return Success(filteredPosts);
}
List<Post> _applyTopPeriod(List<Post> posts, TopPeriodFilter period) {
if (period == TopPeriodFilter.none) return posts;
final sorted = [...posts]..sort((a, b) => b.score.compareTo(a.score));
if (period == TopPeriodFilter.allTime) return sorted;
final now = DateTime.now();
final minDate = period == TopPeriodFilter.month
? now.subtract(const Duration(days: 31))
: DateTime(now.year - 1, now.month, now.day);
final inPeriod = sorted
.where((post) =>
post.createdAt.isAfter(minDate) &&
post.createdAt.isBefore(now.add(const Duration(days: 1))))
.toList(growable: false);
return inPeriod.isEmpty ? sorted : inPeriod;
}
}
bool postMatchesRequestedTags(Post post, List<String> requestedTags) {
@@ -28,7 +28,13 @@ class AppSettings {
required this.whitelistedTags,
required this.smartBlacklistRules,
required this.hideViewedPosts,
required this.mediaQualityMode,
required this.lastFeedTags,
required this.lastFeedProviderIds,
required this.lastFeedTopPeriod,
required this.lastFeedScrollOffset,
this.defaultRatingFilter,
this.lastFeedRating,
});
final List<String> enabledProviderIds;
@@ -48,7 +54,13 @@ class AppSettings {
final List<String> whitelistedTags;
final List<String> smartBlacklistRules;
final bool hideViewedPosts;
final String mediaQualityMode;
final List<String> lastFeedTags;
final List<String> lastFeedProviderIds;
final String lastFeedTopPeriod;
final double lastFeedScrollOffset;
final String? defaultRatingFilter;
final String? lastFeedRating;
static const defaults = AppSettings(
enabledProviderIds: ['gelbooru', 'rule34', 'safebooru'],
@@ -68,7 +80,13 @@ class AppSettings {
whitelistedTags: [],
smartBlacklistRules: [],
hideViewedPosts: false,
mediaQualityMode: 'auto',
lastFeedTags: [],
lastFeedProviderIds: [],
lastFeedTopPeriod: 'none',
lastFeedScrollOffset: 0,
defaultRatingFilter: null,
lastFeedRating: null,
);
AppSettings copyWith({
@@ -89,7 +107,14 @@ class AppSettings {
List<String>? whitelistedTags,
List<String>? smartBlacklistRules,
bool? hideViewedPosts,
String? mediaQualityMode,
List<String>? lastFeedTags,
List<String>? lastFeedProviderIds,
String? lastFeedTopPeriod,
double? lastFeedScrollOffset,
String? defaultRatingFilter,
String? lastFeedRating,
bool clearLastFeedRating = false,
}) {
return AppSettings(
enabledProviderIds: enabledProviderIds ?? this.enabledProviderIds,
@@ -111,7 +136,14 @@ class AppSettings {
whitelistedTags: whitelistedTags ?? this.whitelistedTags,
smartBlacklistRules: smartBlacklistRules ?? this.smartBlacklistRules,
hideViewedPosts: hideViewedPosts ?? this.hideViewedPosts,
mediaQualityMode: mediaQualityMode ?? this.mediaQualityMode,
lastFeedTags: lastFeedTags ?? this.lastFeedTags,
lastFeedProviderIds: lastFeedProviderIds ?? this.lastFeedProviderIds,
lastFeedTopPeriod: lastFeedTopPeriod ?? this.lastFeedTopPeriod,
lastFeedScrollOffset: lastFeedScrollOffset ?? this.lastFeedScrollOffset,
defaultRatingFilter: defaultRatingFilter ?? this.defaultRatingFilter,
lastFeedRating:
clearLastFeedRating ? null : lastFeedRating ?? this.lastFeedRating,
);
}
@@ -133,7 +165,13 @@ class AppSettings {
'whitelistedTags': whitelistedTags,
'smartBlacklistRules': smartBlacklistRules,
'hideViewedPosts': hideViewedPosts,
'mediaQualityMode': mediaQualityMode,
'lastFeedTags': lastFeedTags,
'lastFeedProviderIds': lastFeedProviderIds,
'lastFeedTopPeriod': lastFeedTopPeriod,
'lastFeedScrollOffset': lastFeedScrollOffset,
'defaultRatingFilter': defaultRatingFilter,
'lastFeedRating': lastFeedRating,
};
factory AppSettings.fromJson(Map<String, dynamic> json) => AppSettings(
@@ -178,7 +216,24 @@ class AppSettings {
),
hideViewedPosts:
(json['hideViewedPosts'] as bool?) ?? defaults.hideViewedPosts,
mediaQualityMode:
(json['mediaQualityMode'] as String?) ?? defaults.mediaQualityMode,
lastFeedTags: List<String>.from(
(json['lastFeedTags'] as List?) ?? defaults.lastFeedTags,
),
lastFeedProviderIds: List<String>.from(
(json['lastFeedProviderIds'] as List?) ??
(json['selectedFeedProviderIds'] as List?) ??
defaults.lastFeedProviderIds,
),
lastFeedTopPeriod: (json['lastFeedTopPeriod'] as String?) ??
(json['defaultTopPeriodFilter'] as String?) ??
defaults.lastFeedTopPeriod,
lastFeedScrollOffset:
(json['lastFeedScrollOffset'] as num?)?.toDouble() ??
defaults.lastFeedScrollOffset,
defaultRatingFilter: json['defaultRatingFilter'] as String?,
lastFeedRating: json['lastFeedRating'] as String?,
);
}