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
320 lines
9.8 KiB
Dart
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);
|
|
}
|