feat: add HTML providers for Realbooru and Paheal, hidden posts screen, and UI/search enhancements

This commit is contained in:
2026-09-03 16:40:37 +03:00
parent 7cccb2cd8d
commit 45faa12133
55 changed files with 4263 additions and 2076 deletions
@@ -16,9 +16,12 @@ class DownloadManagerService {
final DownloadedMediaService? _downloadedMediaService;
final _controller = StreamController<List<DownloadTask>>.broadcast();
final Map<String, DownloadTask> _tasks = {};
final Set<String> _runningTaskIds = {};
bool _queuePaused = false;
Stream<List<DownloadTask>> get stream => _controller.stream;
List<DownloadTask> get tasks => List.unmodifiable(_tasks.values);
bool get queuePaused => _queuePaused;
Future<DownloadTask> start(Post post) async {
final id = '${post.cacheKey}:${DateTime.now().microsecondsSinceEpoch}';
@@ -30,7 +33,7 @@ class DownloadManagerService {
status: DownloadTaskStatus.queued,
);
_set(task);
unawaited(_run(task));
_pumpQueue();
return task;
}
@@ -49,10 +52,21 @@ class DownloadManagerService {
openAfterDownload: openAfterDownload,
);
_set(task);
unawaited(_run(task));
_pumpQueue();
return task;
}
void pauseQueue() {
_queuePaused = true;
_emit();
}
void resumeQueue() {
_queuePaused = false;
_emit();
_pumpQueue();
}
Future<void> retry(String taskId) async {
final existing = _tasks[taskId];
if (existing == null) return;
@@ -62,7 +76,7 @@ class DownloadManagerService {
error: null,
);
_set(task);
await _run(task);
_pumpQueue();
}
void cancel(String taskId) {
@@ -71,8 +85,19 @@ class DownloadManagerService {
_set(existing.copyWith(status: DownloadTaskStatus.canceled));
}
void clearFinished() {
_tasks.removeWhere(
(_, task) =>
task.status == DownloadTaskStatus.completed ||
task.status == DownloadTaskStatus.failed ||
task.status == DownloadTaskStatus.canceled,
);
_emit();
}
Future<void> _run(DownloadTask task) async {
if (_tasks[task.id]?.status == DownloadTaskStatus.canceled) return;
if (!_runningTaskIds.add(task.id)) return;
_set(task.copyWith(status: DownloadTaskStatus.running));
try {
final saved = task.sourceUrl == null
@@ -121,14 +146,30 @@ class DownloadManagerService {
error: error.toString(),
),
);
} finally {
_runningTaskIds.remove(task.id);
_pumpQueue();
}
}
void _set(DownloadTask task) {
_tasks[task.id] = task;
_emit();
}
void _emit() {
_controller.add(tasks);
}
void _pumpQueue() {
if (_queuePaused) return;
for (final task in tasks) {
if (task.status != DownloadTaskStatus.queued) continue;
if (_runningTaskIds.contains(task.id)) continue;
unawaited(_run(task));
}
}
void _updateProgress(String taskId, int received, int total) {
if (total <= 0) return;
final current = _tasks[taskId];
@@ -143,7 +184,7 @@ class DownloadManagerService {
final current = _tasks[taskId];
if (current?.status != DownloadTaskStatus.completed) return;
_tasks.remove(taskId);
_controller.add(tasks);
_emit();
});
}
+34 -2
View File
@@ -28,7 +28,12 @@ class DownloadService {
if (Platform.isAndroid) {
final tempDir = await getTemporaryDirectory();
final tempPath = p.join(tempDir.path, fileName);
await _dio.download(url, tempPath, onReceiveProgress: onProgress);
await _dio.download(
url,
tempPath,
onReceiveProgress: onProgress,
options: Options(headers: _headersFor(post)),
);
final saved = await _channel.invokeMethod<String>('saveToDownloads', {
'path': tempPath,
'fileName': fileName,
@@ -39,7 +44,12 @@ class DownloadService {
final location = await getSaveLocation(suggestedName: fileName);
if (location == null) return null;
await _dio.download(url, location.path, onReceiveProgress: onProgress);
await _dio.download(
url,
location.path,
onReceiveProgress: onProgress,
options: Options(headers: _headersFor(post)),
);
return location.path;
}
@@ -120,4 +130,26 @@ class DownloadService {
if (value.contains('.png') || value.contains('png')) return 'image/png';
return 'image/jpeg';
}
Map<String, String> _headersFor(Post post) {
final lower = '${post.providerId} ${post.providerName} '
'${post.previewUrl} ${post.sampleUrl} ${post.fileUrl}'
.toLowerCase();
return {
'User-Agent': lower.contains('realbooru') || lower.contains('paheal')
? 'Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 Chrome/125 Mobile Safari/537.36'
: 'Lunaris/2.0.1 Flutter local booru browser',
'Accept': lower.contains('realbooru') || lower.contains('paheal')
? 'video/webm,video/mp4,image/avif,image/webp,image/apng,image/*,*/*;q=0.8'
: '*/*',
if (lower.contains('gelbooru')) 'Referer': 'https://gelbooru.com/',
if (lower.contains('rule34') && !lower.contains('paheal'))
'Referer': 'https://rule34.xxx/',
if (lower.contains('realbooru'))
'Referer':
'https://realbooru.com/index.php?page=post&s=view&id=${post.id}',
if (lower.contains('paheal'))
'Referer': 'https://rule34.paheal.net/post/view/${post.id}',
};
}
}
+8 -1
View File
@@ -118,7 +118,14 @@ bool postMatchesRequestedTags(Post post, List<String> requestedTags) {
.toList(growable: false);
if (requested.isEmpty) return true;
final postTags = _postTagSet(post);
return requested.every(postTags.contains);
return requested.every(
(requestedTag) => postTags.any(
(postTag) =>
postTag == requestedTag ||
postTag.startsWith('${requestedTag}_') ||
postTag.startsWith('$requestedTag-'),
),
);
}
bool postPassesTagFilters(Post post, AppSettings settings) {
+1 -1
View File
@@ -32,7 +32,7 @@ class SearchService {
);
}
Future<Result<List<SearchHistory>>> recentSearches({int limit = 20}) {
Future<Result<List<SearchHistory>>> recentSearches({int? limit}) {
return _repository.recent(limit: limit);
}
+36 -1
View File
@@ -41,6 +41,7 @@ class AppSettings {
required this.videoPlayerHalfVolume,
required this.videoPlayerLoop,
required this.videoPlayerCover,
required this.videoPlaybackPositions,
required this.hiddenPostKeys,
required this.diagnosticLogLines,
required this.lastFeedTags,
@@ -82,6 +83,7 @@ class AppSettings {
final bool videoPlayerHalfVolume;
final bool videoPlayerLoop;
final bool videoPlayerCover;
final Map<String, int> videoPlaybackPositions;
final List<String> hiddenPostKeys;
final List<String> diagnosticLogLines;
final List<String> lastFeedTags;
@@ -108,7 +110,7 @@ class AppSettings {
mobileColumns: 2,
blurExplicitContent: true,
allowDownloads: true,
autoDownloadFavorites: false,
autoDownloadFavorites: true,
selectedFeedProviderIds: [],
showPostBadges: true,
defaultTopPeriodFilter: 'none',
@@ -123,6 +125,7 @@ class AppSettings {
videoPlayerHalfVolume: false,
videoPlayerLoop: false,
videoPlayerCover: false,
videoPlaybackPositions: {},
hiddenPostKeys: [],
diagnosticLogLines: [],
lastFeedTags: [],
@@ -165,6 +168,7 @@ class AppSettings {
bool? videoPlayerHalfVolume,
bool? videoPlayerLoop,
bool? videoPlayerCover,
Map<String, int>? videoPlaybackPositions,
List<String>? hiddenPostKeys,
List<String>? diagnosticLogLines,
List<String>? lastFeedTags,
@@ -212,6 +216,8 @@ class AppSettings {
videoPlayerHalfVolume ?? this.videoPlayerHalfVolume,
videoPlayerLoop: videoPlayerLoop ?? this.videoPlayerLoop,
videoPlayerCover: videoPlayerCover ?? this.videoPlayerCover,
videoPlaybackPositions:
videoPlaybackPositions ?? this.videoPlaybackPositions,
hiddenPostKeys: hiddenPostKeys ?? this.hiddenPostKeys,
diagnosticLogLines: diagnosticLogLines ?? this.diagnosticLogLines,
lastFeedTags: lastFeedTags ?? this.lastFeedTags,
@@ -256,6 +262,7 @@ class AppSettings {
'videoPlayerHalfVolume': videoPlayerHalfVolume,
'videoPlayerLoop': videoPlayerLoop,
'videoPlayerCover': videoPlayerCover,
'videoPlaybackPositions': videoPlaybackPositions,
'hiddenPostKeys': hiddenPostKeys,
'diagnosticLogLines': diagnosticLogLines,
'lastFeedTags': lastFeedTags,
@@ -334,6 +341,10 @@ class AppSettings {
(json['videoPlayerLoop'] as bool?) ?? defaults.videoPlayerLoop,
videoPlayerCover:
(json['videoPlayerCover'] as bool?) ?? defaults.videoPlayerCover,
videoPlaybackPositions: Map<String, int>.from(
(json['videoPlaybackPositions'] as Map?) ??
defaults.videoPlaybackPositions,
),
hiddenPostKeys: List<String>.from(
(json['hiddenPostKeys'] as List?) ?? defaults.hiddenPostKeys,
),
@@ -469,6 +480,30 @@ class SettingsService {
return updateSettings(settings.copyWith(hiddenPostKeys: const []));
}
Future<Result<void>> saveVideoPlaybackPosition(
String cacheKey,
int milliseconds, {
int maxEntries = 300,
}) async {
if (cacheKey.isEmpty || milliseconds < 1000) return const Success(null);
final result = await getSettings();
if (result is Error<AppSettings>) return Error(result.failure);
final settings = (result as Success<AppSettings>).data;
final existing = settings.videoPlaybackPositions[cacheKey] ?? 0;
if ((existing - milliseconds).abs() < 1000) return const Success(null);
final positions = <String, int>{
...settings.videoPlaybackPositions,
cacheKey: milliseconds,
};
if (positions.length > maxEntries) {
final removeCount = positions.length - maxEntries;
for (final key in positions.keys.take(removeCount).toList()) {
positions.remove(key);
}
}
return updateSettings(settings.copyWith(videoPlaybackPositions: positions));
}
Future<Result<void>> appendDiagnosticLog(String message) async {
final result = await getSettings();
if (result is Error<AppSettings>) return Error(result.failure);
@@ -35,7 +35,38 @@ class ViewedHistoryService {
);
}
Future<Result<List<ViewedPostEntry>>> getViewedPostEntries() async {
final viewedResult = await _repository.recent();
if (viewedResult is Error<List<ViewedPost>>) {
return Error(viewedResult.failure);
}
final viewed = (viewedResult as Success<List<ViewedPost>>).data;
final postsResult = await _postRepository.getCachedPostsByKeys(
viewed.map((item) => item.viewedKey),
);
if (postsResult is Error<List<Post>>) {
return Error(postsResult.failure);
}
final postsByKey = {
for (final post in (postsResult as Success<List<Post>>).data)
post.cacheKey: post,
};
return Success([
for (final item in viewed)
if (postsByKey[item.viewedKey] != null)
ViewedPostEntry(
post: postsByKey[item.viewedKey]!, viewedAt: item.viewedAt),
]);
}
Future<Result<void>> clearHistory() {
return _repository.clear();
}
}
class ViewedPostEntry {
const ViewedPostEntry({required this.post, required this.viewedAt});
final Post post;
final DateTime viewedAt;
}