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.broadcast(); final Map _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 get progressStream => _progressController.stream; /// Start downloading an entire series Future 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 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 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 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 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 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 _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> _fetchAllEntries( String serverId, Feed initialFeed, { CancelToken? cancelToken, }) async { final allEntries = List.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 _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 _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 _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(); } }