worldhopper/lib/providers/download_provider.dart
Felipe M. 58a562abe8
feat: refactor server software abstraction with Kavita REST API integration
Rename all OPDS-prefixed models to generic names (Server, Feed, Entry,
Link, StreamLink), expand the ServerSoftware interface to cover all
server interactions, and implement a full Kavita REST API client that
replaces the OPDS delegation.

- Rename OPDS* models to generic names across ~60 files
- Add database migrations 13 (opds_id → entry_id) and 14 (unify credentials)
- Create OPDSServerSoftware wrapping existing OPDS services
- Create KavitaApiClient for direct Kavita REST API calls
- Create KavitaFeedMapper to convert Kavita JSON to Feed/Entry models
- Rewrite KavitaServerSoftware to use native API (no OPDS delegation)
- Unify server credentials (remove softwareUsername/softwarePassword)
- Simplify add/edit server UI to single auth section
- Add test connection button to server add/edit screens
- Add progress indicator to PublicationCard using local and server data
- Eliminate softwareType branching in reader screens
- Add EntryProgress and fetchEntryProgress to ServerSoftware interface
- Fix Entry.acquisitionLink crash on empty links
- Add 59 new tests covering models, services, and providers
2026-04-06 17:46:48 +02:00

320 lines
9.8 KiB
Dart

import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:worldhopper/database/database.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/providers/feed_provider.dart';
import 'package:worldhopper/repositories/downloaded_chapters_repository.dart';
import 'package:worldhopper/repositories/downloaded_series_repository.dart';
import 'package:worldhopper/repositories/server_repository.dart';
import 'package:worldhopper/services/download_storage_service.dart';
import 'package:worldhopper/services/series_download_service.dart';
// --- Repository providers ---
final downloadedSeriesRepositoryProvider =
Provider<DownloadedSeriesRepository>((ref) {
return DownloadedSeriesRepository(DatabaseHelper.instance);
});
final downloadedChaptersRepositoryProvider =
Provider<DownloadedChaptersRepository>((ref) {
return DownloadedChaptersRepository(DatabaseHelper.instance);
});
// --- Service providers ---
final downloadStorageServiceProvider = Provider<DownloadStorageService>((ref) {
return DownloadStorageService();
});
final seriesDownloadServiceProvider = Provider<SeriesDownloadService>((ref) {
final seriesRepo = ref.watch(downloadedSeriesRepositoryProvider);
final chaptersRepo = ref.watch(downloadedChaptersRepositoryProvider);
final opdsRepo = ref.watch(feedRepositoryProvider);
final service = SeriesDownloadService(
seriesRepo: seriesRepo,
chaptersRepo: chaptersRepo,
opdsRepo: opdsRepo,
);
ref.onDispose(() => service.dispose());
return service;
});
// --- Polling provider ---
/// Whether download polling is active (enables auto-refresh of download lists)
final downloadPollingActiveProvider = StateProvider<bool>((ref) => false);
/// Check if there are any active downloads in the DB
final hasActiveDownloadsProvider = FutureProvider<bool>((ref) async {
final repository = ref.watch(downloadedSeriesRepositoryProvider);
final series = await repository.getByStatus(DownloadStatus.downloading);
return series.isNotEmpty;
});
/// Server lookup by ID (needed for retry operations)
final serverByIdProvider = FutureProvider.family<Server?, String>(
(ref, serverId) async {
final repo = ServerRepository();
return await repo.getServer(serverId);
},
);
// --- Data providers ---
/// All downloaded series for the library section
final downloadedSeriesListProvider =
FutureProvider<List<DownloadedSeries>>((ref) async {
final repository = ref.watch(downloadedSeriesRepositoryProvider);
final isPolling = ref.watch(downloadPollingActiveProvider);
if (isPolling) {
Timer? timer;
timer = Timer.periodic(const Duration(seconds: 2), (_) {
ref.invalidateSelf();
});
ref.onDispose(() => timer?.cancel());
}
return await repository.getAll();
});
/// Chapters for a specific downloaded series
final downloadedChaptersProvider =
FutureProvider.family<List<DownloadedChapter>, String>(
(ref, seriesId) async {
final repository = ref.watch(downloadedChaptersRepositoryProvider);
final isPolling = ref.watch(downloadPollingActiveProvider);
if (isPolling) {
Timer? timer;
timer = Timer.periodic(const Duration(seconds: 2), (_) {
ref.invalidateSelf();
});
ref.onDispose(() => timer?.cancel());
}
return await repository.getChaptersForSeries(seriesId);
},
);
/// Check if a series feed URL is downloaded (for badges on browse screen)
final seriesDownloadStatusProvider =
FutureProvider.family<DownloadedSeries?, SeriesDownloadKey>(
(ref, key) async {
final repository = ref.watch(downloadedSeriesRepositoryProvider);
return await repository.getByFeedUrl(key.serverId, key.feedUrl);
},
);
/// Check if a specific chapter is downloaded (for badges)
final chapterDownloadStatusProvider =
FutureProvider.family<DownloadedChapter?, ChapterDownloadKey>(
(ref, key) async {
final repository = ref.watch(downloadedChaptersRepositoryProvider);
return await repository.getByEntryId(key.serverId, key.entryId);
},
);
/// Find the next downloaded chapter after the current one in the same series
final nextDownloadedChapterProvider =
FutureProvider.family<DownloadedChapter?, ChapterDownloadKey>(
(ref, key) async {
final repository = ref.watch(downloadedChaptersRepositoryProvider);
final current = await repository.getByEntryId(key.serverId, key.entryId);
if (current == null) return null;
final chapters = await repository.getChaptersForSeries(current.seriesId);
final currentIndex = chapters.indexWhere((c) => c.id == current.id);
if (currentIndex < 0 || currentIndex >= chapters.length - 1) return null;
final next = chapters[currentIndex + 1];
return next.isComplete ? next : null;
},
);
/// Stream of active download progress
final activeDownloadProgressProvider = StreamProvider<DownloadProgress>((ref) {
final service = ref.watch(seriesDownloadServiceProvider);
return service.progressStream;
});
// --- Notifier ---
/// Notifier for triggering download/cancel/delete actions
class DownloadNotifier extends StateNotifier<AsyncValue<void>> {
final SeriesDownloadService _downloadService;
final Ref _ref;
DownloadNotifier(this._downloadService, this._ref)
: super(const AsyncValue.data(null));
/// 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 {
state = const AsyncValue.loading();
_ref.read(downloadPollingActiveProvider.notifier).state = true;
try {
state = await AsyncValue.guard(() async {
await _downloadService.startSeriesDownload(
server: server,
feedUrl: feedUrl,
feedTitle: feedTitle,
initialFeed: initialFeed,
coverUrl: coverUrl,
thumbnailUrl: thumbnailUrl,
);
_invalidateProviders();
});
} finally {
_ref.read(downloadPollingActiveProvider.notifier).state = false;
}
}
/// Download a single chapter
Future<void> downloadChapter({
required Server server,
required Entry entry,
required String seriesId,
required String feedUrl,
required String feedTitle,
}) async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
await _downloadService.downloadSingleChapter(
server: server,
entry: entry,
seriesId: seriesId,
feedUrl: feedUrl,
feedTitle: feedTitle,
);
_invalidateProviders();
});
}
/// Cancel an active download
void cancelDownload(String id) {
_downloadService.cancelDownload(id);
_invalidateProviders();
}
/// Delete a downloaded series
Future<void> deleteSeries(String seriesId) async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
await _downloadService.deleteSeries(seriesId);
_invalidateProviders();
});
}
/// Delete a single downloaded chapter
Future<void> deleteChapter(DownloadedChapter chapter) async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
await _downloadService.deleteChapter(chapter);
_invalidateProviders();
});
}
/// Retry a single failed chapter
Future<void> retryChapter({
required Server server,
required DownloadedChapter chapter,
}) async {
_ref.read(downloadPollingActiveProvider.notifier).state = true;
try {
state = await AsyncValue.guard(() async {
await _downloadService.retryChapter(server: server, chapter: chapter);
_invalidateProviders();
});
} finally {
_ref.read(downloadPollingActiveProvider.notifier).state = false;
}
}
/// Retry all failed chapters in a series
Future<void> retryFailedChapters({
required Server server,
required String seriesId,
}) async {
_ref.read(downloadPollingActiveProvider.notifier).state = true;
try {
state = await AsyncValue.guard(() async {
await _downloadService.retryFailedChapters(
server: server,
seriesId: seriesId,
);
_invalidateProviders();
});
} finally {
_ref.read(downloadPollingActiveProvider.notifier).state = false;
}
}
void _invalidateProviders() {
_ref.invalidate(downloadedSeriesListProvider);
_ref.invalidate(seriesDownloadStatusProvider);
_ref.invalidate(chapterDownloadStatusProvider);
}
}
final downloadNotifierProvider =
StateNotifierProvider<DownloadNotifier, AsyncValue<void>>((ref) {
final downloadService = ref.watch(seriesDownloadServiceProvider);
return DownloadNotifier(downloadService, ref);
});
// --- Key classes ---
class SeriesDownloadKey {
final String serverId;
final String feedUrl;
const SeriesDownloadKey({
required this.serverId,
required this.feedUrl,
});
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SeriesDownloadKey &&
runtimeType == other.runtimeType &&
serverId == other.serverId &&
feedUrl == other.feedUrl;
@override
int get hashCode => Object.hash(serverId, feedUrl);
}
class ChapterDownloadKey {
final String serverId;
final String entryId;
const ChapterDownloadKey({
required this.serverId,
required this.entryId,
});
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ChapterDownloadKey &&
runtimeType == other.runtimeType &&
serverId == other.serverId &&
entryId == other.entryId;
@override
int get hashCode => Object.hash(serverId, entryId);
}