worldhopper/lib/services/series_download_service.dart
2026-04-17 11:45:41 +02:00

696 lines
22 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:dio/dio.dart';
import 'package:uuid/uuid.dart';
import 'package:worldhopper/models/downloaded_chapter.dart';
import 'package:worldhopper/models/downloaded_series.dart';
import 'package:worldhopper/models/entry.dart';
import 'package:worldhopper/models/feed.dart';
import 'package:worldhopper/models/server.dart';
import 'package:worldhopper/repositories/downloaded_chapters_repository.dart';
import 'package:worldhopper/repositories/downloaded_series_repository.dart';
import 'package:worldhopper/repositories/feed_repository.dart';
import 'package:worldhopper/services/background_download_service.dart' as bg;
import 'package:worldhopper/services/download_storage_service.dart';
import 'package:worldhopper/services/image_stream_download_service.dart';
import 'package:worldhopper/services/server_software/server_software_service.dart';
/// Progress information for an active download
class DownloadProgress {
final String seriesId;
final String? chapterId;
final String seriesTitle;
final String? chapterTitle;
final int completedChapters;
final int totalChapters;
final int? currentPageInChapter;
final int? totalPagesInChapter;
final int? bytesDownloaded;
final int? bytesTotal;
final DownloadStatus status;
const DownloadProgress({
required this.seriesId,
this.chapterId,
required this.seriesTitle,
this.chapterTitle,
required this.completedChapters,
required this.totalChapters,
this.currentPageInChapter,
this.totalPagesInChapter,
this.bytesDownloaded,
this.bytesTotal,
required this.status,
});
double get overallProgress {
if (totalChapters == 0) return 0.0;
return (completedChapters / totalChapters).clamp(0.0, 1.0);
}
/// Progress within the current chapter (0.0 to 1.0)
double get chapterProgress {
if (currentPageInChapter != null &&
totalPagesInChapter != null &&
totalPagesInChapter! > 0) {
return (currentPageInChapter! / totalPagesInChapter!).clamp(0.0, 1.0);
}
if (bytesDownloaded != null && bytesTotal != null && bytesTotal! > 0) {
return (bytesDownloaded! / bytesTotal!).clamp(0.0, 1.0);
}
return 0.0;
}
}
/// Orchestrates full series downloads
class SeriesDownloadService {
final DownloadedSeriesRepository _seriesRepo;
final DownloadedChaptersRepository _chaptersRepo;
final FeedRepository _opdsRepo;
final DownloadStorageService _storageService;
final ImageStreamDownloadService _imageStreamService;
final ServerSoftwareService _softwareService;
final _progressController = StreamController<DownloadProgress>.broadcast();
final Map<String, CancelToken> _activeCancelTokens = {};
// Tracking state for progress callbacks
String? _currentSeriesTitle;
int _currentCompletedCount = 0;
int _currentTotalChapters = 0;
SeriesDownloadService({
required DownloadedSeriesRepository seriesRepo,
required DownloadedChaptersRepository chaptersRepo,
required FeedRepository opdsRepo,
DownloadStorageService? storageService,
ImageStreamDownloadService? imageStreamService,
ServerSoftwareService? softwareService,
}) : _seriesRepo = seriesRepo,
_chaptersRepo = chaptersRepo,
_opdsRepo = opdsRepo,
_storageService = storageService ?? DownloadStorageService(),
_imageStreamService =
imageStreamService ?? ImageStreamDownloadService(),
_softwareService = softwareService ?? ServerSoftwareService();
/// Stream of download progress updates
Stream<DownloadProgress> get progressStream => _progressController.stream;
/// Start downloading an entire series
Future<void> startSeriesDownload({
required Server server,
required String feedUrl,
required String feedTitle,
required Feed initialFeed,
String? coverUrl,
String? thumbnailUrl,
}) async {
const uuid = Uuid();
final seriesId = uuid.v4();
final cancelToken = CancelToken();
_activeCancelTokens[seriesId] = cancelToken;
try {
// 1. Fetch all paginated pages to get complete chapter list
final allEntries = await _fetchAllEntries(
server.id,
initialFeed,
cancelToken: cancelToken,
);
// Filter to only downloadable entries
final downloadableEntries =
allEntries.where((e) => e.isEpub || e.isImageStream).toList();
if (downloadableEntries.isEmpty) {
throw Exception('No downloadable content found in this series');
}
// 2. Create the series record
final now = DateTime.now();
final series = DownloadedSeries(
id: seriesId,
serverId: server.id,
seriesFeedUrl: feedUrl,
title: feedTitle,
coverUrl: coverUrl,
thumbnailUrl: thumbnailUrl,
status: DownloadStatus.downloading,
totalChapters: downloadableEntries.length,
downloadedChaptersCount: 0,
updatedAt: now,
);
await _seriesRepo.save(series);
// 3. Create chapter records
for (var i = 0; i < downloadableEntries.length; i++) {
final entry = downloadableEntries[i];
final chapterId = uuid.v4();
final contentType = entry.isImageStream
? ChapterContentType.imageStream
: ChapterContentType.epub;
final chapter = DownloadedChapter(
id: chapterId,
seriesId: seriesId,
serverId: server.id,
entryId: entry.id,
title: entry.title,
contentType: contentType,
status: DownloadStatus.pending,
totalPages: entry.streamLink?.pageCount ?? 0,
entryJson: jsonEncode(entry.toJson()),
coverUrl: entry.thumbnailUrl ?? entry.coverUrl,
sortOrder: i,
updatedAt: now,
);
await _chaptersRepo.save(chapter);
}
// 4. Download chapters (main isolate + foreground notification)
_currentSeriesTitle = feedTitle;
_currentCompletedCount = 0;
_currentTotalChapters = downloadableEntries.length;
await _downloadChaptersWithNotification(
seriesId: seriesId,
server: server,
);
} catch (e, stackTrace) {
debugPrint('Series download failed for "$feedTitle" '
'(id=$seriesId): $e\n$stackTrace');
await _seriesRepo.updateStatus(seriesId, DownloadStatus.failed);
_emitProgress(
seriesId: seriesId,
seriesTitle: feedTitle,
completedChapters: 0,
totalChapters: 0,
status: DownloadStatus.failed,
);
} finally {
_activeCancelTokens.remove(seriesId);
}
}
/// Download a single chapter (can be called standalone)
Future<void> downloadSingleChapter({
required Server server,
required Entry entry,
required String seriesId,
required String feedUrl,
required String feedTitle,
}) async {
const uuid = Uuid();
// Ensure series record exists
var series = await _seriesRepo.getByFeedUrl(server.id, feedUrl);
if (series == null) {
final now = DateTime.now();
series = DownloadedSeries(
id: seriesId.isEmpty ? uuid.v4() : seriesId,
serverId: server.id,
seriesFeedUrl: feedUrl,
title: feedTitle,
status: DownloadStatus.partial,
totalChapters: 1,
downloadedChaptersCount: 0,
updatedAt: now,
);
await _seriesRepo.save(series);
}
// Check if chapter already exists
var existing = await _chaptersRepo.getByEntryId(server.id, entry.id);
if (existing != null && existing.isComplete) return;
final chapterId = existing?.id ?? uuid.v4();
final contentType = entry.isImageStream
? ChapterContentType.imageStream
: ChapterContentType.epub;
final now = DateTime.now();
final chapter = DownloadedChapter(
id: chapterId,
seriesId: series.id,
serverId: server.id,
entryId: entry.id,
title: entry.title,
contentType: contentType,
status: DownloadStatus.pending,
totalPages: entry.streamLink?.pageCount ?? 0,
entryJson: jsonEncode(entry.toJson()),
coverUrl: entry.thumbnailUrl ?? entry.coverUrl,
sortOrder: 0,
updatedAt: now,
);
await _chaptersRepo.save(chapter);
final cancelToken = CancelToken();
_activeCancelTokens[chapterId] = cancelToken;
try {
await _downloadChapter(server, chapter, cancelToken);
// Update series counts
final allChapters = await _chaptersRepo.getChaptersForSeries(series.id);
final completedCount = allChapters.where((c) => c.isComplete).length;
await _seriesRepo.updateDownloadedCount(series.id, completedCount);
if (completedCount == series.totalChapters) {
await _seriesRepo.updateStatus(series.id, DownloadStatus.complete);
} else {
await _seriesRepo.updateStatus(series.id, DownloadStatus.partial);
}
} catch (e, stackTrace) {
debugPrint('Single chapter download failed '
'(id=$chapterId, series=${series.id}): $e\n$stackTrace');
await _chaptersRepo.updateStatus(
chapterId,
DownloadStatus.failed,
errorMessage: _describeError(e),
);
rethrow;
} finally {
_activeCancelTokens.remove(chapterId);
}
}
/// Retry a single failed chapter
Future<void> retryChapter({
required Server server,
required DownloadedChapter chapter,
}) async {
final cancelToken = CancelToken();
_activeCancelTokens[chapter.id] = cancelToken;
// Set up tracking state
final series = await _seriesRepo.getById(chapter.seriesId);
if (series != null) {
_currentSeriesTitle = series.title;
_currentTotalChapters = series.totalChapters;
_currentCompletedCount = series.downloadedChaptersCount;
}
try {
await _chaptersRepo.updateStatus(chapter.id, DownloadStatus.pending);
await _downloadChapter(server, chapter, cancelToken);
// Update series counts
final allChapters =
await _chaptersRepo.getChaptersForSeries(chapter.seriesId);
final completedCount = allChapters.where((c) => c.isComplete).length;
await _seriesRepo.updateDownloadedCount(chapter.seriesId, completedCount);
final hasFailures =
allChapters.any((c) => c.status == DownloadStatus.failed);
final newStatus = completedCount == allChapters.length
? DownloadStatus.complete
: hasFailures
? DownloadStatus.partial
: DownloadStatus.downloading;
await _seriesRepo.updateStatus(chapter.seriesId, newStatus);
} catch (e, stackTrace) {
debugPrint('Retry failed for chapter "${chapter.title}" '
'(id=${chapter.id}): $e\n$stackTrace');
await _chaptersRepo.updateStatus(
chapter.id,
DownloadStatus.failed,
errorMessage: _describeError(e),
);
} finally {
_activeCancelTokens.remove(chapter.id);
}
}
/// Retry all failed chapters in a series
Future<void> retryFailedChapters({
required Server server,
required String seriesId,
}) async {
final series = await _seriesRepo.getById(seriesId);
if (series == null) {
throw StateError('Cannot retry: series $seriesId not found in database');
}
// Set up tracking state
_currentSeriesTitle = series.title;
_currentTotalChapters = series.totalChapters;
_currentCompletedCount = series.downloadedChaptersCount;
// Reset failed chapters to pending
final pendingChapters = await _chaptersRepo.getPendingChapters(seriesId);
for (final chapter in pendingChapters) {
if (chapter.status == DownloadStatus.failed) {
await _chaptersRepo.updateStatus(chapter.id, DownloadStatus.pending);
}
}
await _seriesRepo.updateStatus(seriesId, DownloadStatus.downloading);
await _downloadChaptersWithNotification(
seriesId: seriesId,
server: server,
);
}
/// Cancel an active download
void cancelDownload(String id) {
_activeCancelTokens[id]?.cancel();
_activeCancelTokens.remove(id);
}
/// Delete a downloaded series and its files
Future<void> deleteSeries(String seriesId) async {
cancelDownload(seriesId);
await _storageService.deleteSeriesFiles(seriesId);
await _chaptersRepo.deleteForSeries(seriesId);
await _seriesRepo.delete(seriesId);
}
/// Delete a single downloaded chapter and its files
Future<void> deleteChapter(DownloadedChapter chapter) async {
cancelDownload(chapter.id);
await _storageService.deleteChapterFiles(
chapter.seriesId,
chapter.id,
chapter.contentType == ChapterContentType.epub,
);
await _chaptersRepo.delete(chapter.id);
// Update series counts
final remaining =
await _chaptersRepo.getChaptersForSeries(chapter.seriesId);
if (remaining.isEmpty) {
await _seriesRepo.delete(chapter.seriesId);
} else {
final completedCount = remaining.where((c) => c.isComplete).length;
await _seriesRepo.updateDownloadedCount(chapter.seriesId, completedCount);
final series = await _seriesRepo.getById(chapter.seriesId);
if (series != null) {
await _seriesRepo.save(series.copyWith(
totalChapters: remaining.length,
status: completedCount == remaining.length
? DownloadStatus.complete
: DownloadStatus.partial,
));
}
}
}
/// Download chapters in the main isolate while keeping a foreground
/// notification alive via the background service.
Future<void> _downloadChaptersWithNotification({
required String seriesId,
required Server server,
}) async {
final cancelToken = _activeCancelTokens[seriesId] ?? CancelToken();
_activeCancelTokens[seriesId] = cancelToken;
// Start the Android foreground service so the OS keeps us alive
await bg.startForegroundService();
int completedCount = 0;
bool hasFailures = false;
DownloadStatus finalStatus = DownloadStatus.partial;
try {
final chapters = await _chaptersRepo.getChaptersForSeries(seriesId);
for (final chapter in chapters) {
if (cancelToken.isCancelled) break;
if (chapter.isComplete) {
completedCount++;
continue;
}
bg.updateDownloadNotification(
title: _currentSeriesTitle ?? '',
content: '${chapter.title} ($completedCount/$_currentTotalChapters)',
progress: completedCount * 100,
maxProgress: _currentTotalChapters * 100,
force: true,
);
_emitProgress(
seriesId: seriesId,
chapterId: chapter.id,
seriesTitle: _currentSeriesTitle ?? '',
chapterTitle: chapter.title,
completedChapters: completedCount,
totalChapters: _currentTotalChapters,
status: DownloadStatus.downloading,
);
try {
await _downloadChapter(server, chapter, cancelToken);
completedCount++;
_currentCompletedCount = completedCount;
await _seriesRepo.updateDownloadedCount(seriesId, completedCount);
} catch (e, stackTrace) {
if (cancelToken.isCancelled) break;
debugPrint('Download failed for chapter "${chapter.title}" '
'(id=${chapter.id}, series=$seriesId): $e\n$stackTrace');
hasFailures = true;
try {
await _chaptersRepo.updateStatus(
chapter.id,
DownloadStatus.failed,
errorMessage: _describeError(e),
);
} catch (dbError) {
debugPrint('Failed to persist error status for chapter '
'${chapter.id}: $dbError');
}
}
}
finalStatus = (cancelToken.isCancelled || hasFailures)
? DownloadStatus.partial
: DownloadStatus.complete;
await _seriesRepo.updateStatus(seriesId, finalStatus);
_emitProgress(
seriesId: seriesId,
seriesTitle: _currentSeriesTitle ?? '',
completedChapters: completedCount,
totalChapters: _currentTotalChapters,
status: finalStatus,
);
} finally {
// Always tear down the foreground service and notification
bg.dismissDownloadNotification();
await bg.stopForegroundService();
}
if (finalStatus == DownloadStatus.complete) {
bg.showDownloadCompleteNotification(
title: 'Download complete',
body:
'${_currentSeriesTitle ?? 'Series'}$completedCount chapters downloaded',
);
}
}
/// Fetch all entries across paginated feed pages
Future<List<Entry>> _fetchAllEntries(
String serverId,
Feed initialFeed, {
CancelToken? cancelToken,
}) async {
final allEntries = List<Entry>.from(initialFeed.entries);
var nextUrl = initialFeed.nextLink?.href;
while (nextUrl != null) {
if (cancelToken?.isCancelled == true) break;
final nextFeed = await _opdsRepo.fetchFeed(
serverId,
nextUrl,
cancelToken: cancelToken,
);
allEntries.addAll(nextFeed.entries);
nextUrl = nextFeed.nextLink?.href;
}
return allEntries;
}
/// Download a single chapter's content
Future<void> _downloadChapter(
Server server,
DownloadedChapter chapter,
CancelToken cancelToken,
) async {
await _chaptersRepo.updateStatus(chapter.id, DownloadStatus.downloading);
if (chapter.contentType == ChapterContentType.epub) {
await _downloadEpubChapter(server, chapter, cancelToken);
} else {
await _downloadImageStreamChapter(server, chapter, cancelToken);
}
}
/// Download an EPUB chapter directly to persistent storage
Future<void> _downloadEpubChapter(
Server server,
DownloadedChapter chapter,
CancelToken cancelToken,
) async {
final entry = chapter.toEntry();
final epubPath =
await _storageService.getEpubPath(chapter.seriesId, chapter.id);
final software = _softwareService.getImplementation(server);
await software.downloadEpub(
server,
entry,
savePath: epubPath,
cancelToken: cancelToken,
onProgress: (progress) {
_emitProgress(
seriesId: chapter.seriesId,
chapterId: chapter.id,
seriesTitle: _currentSeriesTitle ?? '',
chapterTitle: chapter.title,
completedChapters: _currentCompletedCount,
totalChapters: _currentTotalChapters,
bytesDownloaded: (progress * 100).toInt(),
bytesTotal: 100,
status: DownloadStatus.downloading,
);
},
);
await _chaptersRepo.updateStatus(
chapter.id,
DownloadStatus.complete,
filePath: epubPath,
);
}
/// Download an image stream chapter
Future<void> _downloadImageStreamChapter(
Server server,
DownloadedChapter chapter,
CancelToken cancelToken,
) async {
final entry = chapter.toEntry();
final streamLink = entry.streamLink;
if (streamLink == null) {
throw Exception('No stream link for ${chapter.title}');
}
final chapterPath = await _imageStreamService.downloadChapter(
server: server,
streamLink: streamLink,
seriesId: chapter.seriesId,
chapterId: chapter.id,
cancelToken: cancelToken,
onProgress: (downloaded, total) {
_chaptersRepo.updateDownloadedPages(chapter.id, downloaded).catchError(
(e) => debugPrint('Failed to update download progress for '
'chapter ${chapter.id}: $e'),
);
_emitProgress(
seriesId: chapter.seriesId,
chapterId: chapter.id,
seriesTitle: _currentSeriesTitle ?? '',
chapterTitle: chapter.title,
completedChapters: _currentCompletedCount,
totalChapters: _currentTotalChapters,
currentPageInChapter: downloaded,
totalPagesInChapter: total,
status: DownloadStatus.downloading,
);
},
);
await _chaptersRepo.updateStatus(
chapter.id,
DownloadStatus.complete,
filePath: chapterPath,
);
}
void _emitProgress({
required String seriesId,
String? chapterId,
required String seriesTitle,
String? chapterTitle,
required int completedChapters,
required int totalChapters,
int? currentPageInChapter,
int? totalPagesInChapter,
int? bytesDownloaded,
int? bytesTotal,
required DownloadStatus status,
}) {
final progress = DownloadProgress(
seriesId: seriesId,
chapterId: chapterId,
seriesTitle: seriesTitle,
chapterTitle: chapterTitle,
completedChapters: completedChapters,
totalChapters: totalChapters,
currentPageInChapter: currentPageInChapter,
totalPagesInChapter: totalPagesInChapter,
bytesDownloaded: bytesDownloaded,
bytesTotal: bytesTotal,
status: status,
);
_progressController.add(progress);
// Update Android notification with progress bar
if (status == DownloadStatus.downloading && totalChapters > 0) {
final chapterPct = progress.chapterProgress;
final overallPct = (completedChapters * 100 + (chapterPct * 100).round())
.clamp(0, totalChapters * 100);
bg.updateDownloadNotification(
title: seriesTitle,
content: chapterTitle != null
? '$chapterTitle ($completedChapters/$totalChapters)'
: '$completedChapters/$totalChapters chapters',
progress: overallPct,
maxProgress: totalChapters * 100,
);
}
}
String _describeError(Object error) {
if (error is DioException) {
switch (error.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
return 'Connection timed out';
case DioExceptionType.cancel:
return 'Download cancelled';
case DioExceptionType.badResponse:
final statusCode = error.response?.statusCode;
if (statusCode != null) {
return 'Server returned $statusCode';
}
return 'Server error';
default:
return 'Network error';
}
}
if (error is FileSystemException) {
return 'Disk error: ${error.message}';
}
return error.toString().length > 100
? '${error.toString().substring(0, 100)}...'
: error.toString();
}
void dispose() {
for (final token in _activeCancelTokens.values) {
token.cancel();
}
_activeCancelTokens.clear();
_progressController.close();
}
}