Skip to content

Commit 6ffe589

Browse files
committed
Add client media upload and cache pipeline
1 parent 4eb20e2 commit 6ffe589

30 files changed

Lines changed: 1054 additions & 11 deletions

app/lib/auth/auth_api_client.dart

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import 'dart:convert';
2+
import 'dart:typed_data';
23

34
import 'package:http/http.dart' as http;
5+
import 'package:http_parser/http_parser.dart';
46
import 'package:papyrus/auth/auth_models.dart';
57
import 'package:papyrus/auth/papyrus_api_config.dart';
8+
import 'package:papyrus/media/media_models.dart';
69

710
class AuthApiException implements Exception {
811
final int statusCode;
@@ -151,6 +154,46 @@ class AuthApiClient {
151154
await _postJson(config.endpoint('/sync/powersync-upload'), accessToken: accessToken, body: {'batch': batch});
152155
}
153156

157+
Future<MediaStorageUsage> fetchMediaUsage(String accessToken) async {
158+
final json = await _getJson(config.endpoint('/media/usage'), accessToken: accessToken);
159+
return MediaStorageUsage.fromJson(json);
160+
}
161+
162+
Future<MediaAsset> uploadMedia(String accessToken, MediaUploadPayload payload) async {
163+
final request = http.MultipartRequest('POST', config.endpoint('/media'))
164+
..headers.addAll(_authHeaders(accessToken))
165+
..fields['book_id'] = payload.bookId
166+
..fields['kind'] = payload.kind.apiValue
167+
..files.add(
168+
http.MultipartFile.fromBytes(
169+
'file',
170+
payload.bytes,
171+
filename: payload.filename,
172+
contentType: _mediaType(payload.contentType),
173+
),
174+
);
175+
176+
final response = await http.Response.fromStream(await _httpClient.send(request));
177+
return MediaAsset.fromJson(_decodeResponse(response));
178+
}
179+
180+
Future<Uint8List> downloadMedia(String accessToken, String assetId) async {
181+
final response = await _httpClient.get(config.endpoint('/media/$assetId'), headers: _authHeaders(accessToken));
182+
if (response.statusCode >= 200 && response.statusCode < 300) {
183+
return response.bodyBytes;
184+
}
185+
_decodeResponse(response);
186+
throw const AuthApiException(statusCode: 0, message: 'Media download failed');
187+
}
188+
189+
Future<void> deleteMedia(String accessToken, String assetId) async {
190+
final response = await _httpClient.delete(config.endpoint('/media/$assetId'), headers: _authHeaders(accessToken));
191+
if (response.statusCode >= 200 && response.statusCode < 300) {
192+
return;
193+
}
194+
_decodeResponse(response);
195+
}
196+
154197
Future<Map<String, dynamic>> _getJson(Uri uri, {String? accessToken}) async {
155198
final response = await _httpClient.get(uri, headers: _headers(accessToken));
156199

@@ -185,6 +228,16 @@ class AuthApiClient {
185228
};
186229
}
187230

231+
Map<String, String> _authHeaders(String accessToken) {
232+
return {'Accept': 'application/json', 'Authorization': 'Bearer $accessToken'};
233+
}
234+
235+
MediaType _mediaType(String contentType) {
236+
final parts = contentType.split('/');
237+
if (parts.length != 2) return MediaType('application', 'octet-stream');
238+
return MediaType(parts[0], parts[1]);
239+
}
240+
188241
Map<String, dynamic> _decodeResponse(http.Response response) {
189242
final decoded = response.body.isEmpty ? <String, dynamic>{} : jsonDecode(response.body) as Map<String, dynamic>;
190243

app/lib/auth/auth_repository.dart

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
44
import 'package:flutter_web_auth_2/flutter_web_auth_2.dart';
55
import 'package:papyrus/auth/auth_api_client.dart';
66
import 'package:papyrus/auth/auth_models.dart';
7+
import 'package:papyrus/media/media_models.dart';
78
import 'package:papyrus/auth/token_store.dart';
89
import 'package:papyrus/platform/web_redirect.dart';
910

@@ -194,6 +195,30 @@ class AuthRepository {
194195
});
195196
}
196197

198+
Future<MediaStorageUsage> fetchMediaUsage() {
199+
return _withFreshAccessToken((accessToken) {
200+
return apiClient.fetchMediaUsage(accessToken);
201+
});
202+
}
203+
204+
Future<MediaAsset> uploadMedia(MediaUploadPayload payload) {
205+
return _withFreshAccessToken((accessToken) {
206+
return apiClient.uploadMedia(accessToken, payload);
207+
});
208+
}
209+
210+
Future<Uint8List> downloadMedia(String assetId) {
211+
return _withFreshAccessToken((accessToken) {
212+
return apiClient.downloadMedia(accessToken, assetId);
213+
});
214+
}
215+
216+
Future<void> deleteMedia(String assetId) {
217+
return _withFreshAccessToken((accessToken) {
218+
return apiClient.deleteMedia(accessToken, assetId);
219+
});
220+
}
221+
197222
Future<void> clearTokens() {
198223
return tokenStore.clear();
199224
}

app/lib/data/data_store.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,8 @@ class DataStore extends ChangeNotifier {
9797
if (repository == null) {
9898
throw StateError('Book repository is not initialized');
9999
}
100+
_books[book.id] = book;
101+
notifyListeners();
100102
unawaited(repository.upsert(book));
101103
}
102104

@@ -105,6 +107,8 @@ class DataStore extends ChangeNotifier {
105107
if (repository == null) {
106108
throw StateError('Book repository is not initialized');
107109
}
110+
_books[book.id] = book;
111+
notifyListeners();
108112
unawaited(repository.upsert(book));
109113
}
110114

app/lib/main.dart

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,18 @@ import 'package:papyrus/auth/auth_repository.dart';
77
import 'package:papyrus/auth/papyrus_api_config.dart';
88
import 'package:papyrus/auth/token_store.dart';
99
import 'package:papyrus/data/data_store.dart';
10+
import 'package:papyrus/media/media_cache_service.dart';
11+
import 'package:papyrus/media/media_models.dart';
12+
import 'package:papyrus/media/media_upload_queue.dart';
1013
import 'package:papyrus/powersync/powersync_service.dart';
1114
import 'package:papyrus/powersync/papyrus_powersync_connector.dart';
1215
import 'package:papyrus/powersync/sync_state.dart';
1316
import 'package:papyrus/providers/auth_provider.dart';
1417
import 'package:papyrus/providers/library_provider.dart';
1518
import 'package:papyrus/providers/preferences_provider.dart';
1619
import 'package:papyrus/providers/sync_settings_provider.dart';
20+
import 'package:papyrus/services/book_import_service_stub.dart'
21+
if (dart.library.js_interop) 'package:papyrus/services/book_import_service.dart';
1722
import 'package:papyrus/providers/sidebar_provider.dart';
1823
import 'package:papyrus/themes/app_theme.dart';
1924
import 'package:provider/provider.dart';
@@ -42,6 +47,8 @@ class _PapyrusState extends State<Papyrus> {
4247
late final DataStore _dataStore;
4348
late final AuthProvider _authProvider;
4449
late final SyncSettingsProvider _syncSettingsProvider;
50+
late final MediaUploadQueue _mediaUploadQueue;
51+
late final BookImportService _bookImportService;
4552
late final PapyrusPowerSyncService _powerSyncService;
4653
late final PapyrusApiConfig _officialApiConfig;
4754
late AuthRepository _authRepository;
@@ -59,10 +66,15 @@ class _PapyrusState extends State<Papyrus> {
5966
_authRepository = _buildAuthRepository(_syncSettingsProvider.activeApiConfig, _activeProfileKey);
6067

6168
_dataStore = DataStore();
69+
_mediaUploadQueue = MediaUploadQueue(widget.prefs);
70+
_bookImportService = BookImportService();
6271
_authProvider = AuthProvider(widget.prefs, repository: _authRepository);
6372
_powerSyncService = PapyrusPowerSyncService(
64-
connectorFactory: () =>
65-
PapyrusPowerSyncConnector(authRepository: _authRepository, config: _syncSettingsProvider.activeApiConfig),
73+
connectorFactory: () => PapyrusPowerSyncConnector(
74+
authRepository: _authRepository,
75+
config: _syncSettingsProvider.activeApiConfig,
76+
onUploadComplete: _processMediaUploads,
77+
),
6678
);
6779
unawaited(_dataStore.attachBookRepository(_powerSyncService));
6880
_appRouter = AppRouter(authProvider: _authProvider);
@@ -76,6 +88,7 @@ class _PapyrusState extends State<Papyrus> {
7688
_authProvider.removeListener(_syncPowerSyncAuthState);
7789
_syncSettingsProvider.removeListener(_handleSyncSettingsChanged);
7890
unawaited(_disposeDataServices());
91+
_bookImportService.dispose();
7992
_authProvider.dispose();
8093
_syncSettingsProvider.dispose();
8194
super.dispose();
@@ -99,6 +112,8 @@ class _PapyrusState extends State<Papyrus> {
99112
if (user != null && !_authProvider.isOfflineMode) {
100113
final userId = user.userId;
101114
unawaited(_powerSyncService.activateAuthenticated(userId, profileKey: _activeProfileKey));
115+
unawaited(_refreshMediaUsage());
116+
unawaited(_processMediaUploads());
102117
return;
103118
}
104119

@@ -128,20 +143,52 @@ class _PapyrusState extends State<Papyrus> {
128143
await _powerSyncService.deactivate(clearAuthenticated: false);
129144
_authRepository = _buildAuthRepository(_syncSettingsProvider.activeApiConfig, _activeProfileKey);
130145
await _authProvider.replaceRepository(_authRepository, bootstrapNewRepository: !_authProvider.isOfflineMode);
146+
unawaited(_refreshMediaUsage());
131147
} finally {
132148
_switchingSyncProfile = false;
133149
_syncPowerSyncAuthState();
134150
}
135151
}
136152

153+
Future<void> _refreshMediaUsage() async {
154+
if (!_authProvider.isSignedIn || _authProvider.isOfflineMode) return;
155+
try {
156+
await _mediaUploadQueue.refreshUsage(_authRepository.fetchMediaUsage);
157+
} catch (_) {
158+
// Usage is informational; failed refresh must not block data sync.
159+
}
160+
}
161+
162+
Future<void> _processMediaUploads() async {
163+
if (!_authProvider.isSignedIn || _authProvider.isOfflineMode) return;
164+
await _mediaUploadQueue.processPending(
165+
dataStore: _dataStore,
166+
readBookFile: _bookImportService.getBookFile,
167+
uploadMedia: (payload) async {
168+
try {
169+
return await _authRepository.uploadMedia(payload);
170+
} on AuthApiException catch (error) {
171+
if (error.statusCode == 409) {
172+
throw const MediaUploadException.storageFull();
173+
}
174+
rethrow;
175+
}
176+
},
177+
);
178+
await _refreshMediaUsage();
179+
}
180+
137181
@override
138182
Widget build(BuildContext context) {
139183
return MultiProvider(
140184
providers: [
141185
// Core data store - single source of truth
142186
ChangeNotifierProvider.value(value: _dataStore),
187+
ChangeNotifierProvider.value(value: _mediaUploadQueue),
143188
ChangeNotifierProvider.value(value: _syncSettingsProvider),
144189
Provider.value(value: _powerSyncService),
190+
Provider.value(value: _bookImportService),
191+
Provider(create: _createMediaCacheService),
145192
StreamProvider<SyncState>.value(value: _powerSyncService.syncStates, initialData: _powerSyncService.syncState),
146193
// Auth and UI state providers
147194
ChangeNotifierProvider.value(value: _authProvider),
@@ -165,3 +212,5 @@ class _PapyrusState extends State<Papyrus> {
165212
);
166213
}
167214
}
215+
216+
MediaCacheService _createMediaCacheService(BuildContext _) => const MediaCacheService();
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import 'dart:typed_data';
2+
3+
import 'package:crypto/crypto.dart';
4+
import 'package:papyrus/models/book.dart';
5+
6+
typedef LocalBookFileReader = Future<Uint8List?> Function(String bookId);
7+
typedef LocalBookFileWriter = Future<void> Function(String bookId, String extension, Uint8List bytes);
8+
typedef MediaDownloader = Future<Uint8List> Function(String assetId);
9+
10+
/// Coordinates lazy download and platform-local caching for private media.
11+
class MediaCacheService {
12+
const MediaCacheService();
13+
14+
/// Returns a cached book file when present and, if the book has a stored
15+
/// hash, the bytes match the expected hash.
16+
Future<Uint8List?> getValidCachedBookFile(Book book, {required LocalBookFileReader readLocalBookFile}) async {
17+
final cached = await readLocalBookFile(book.id);
18+
if (cached == null) return null;
19+
return _matchesExpectedHash(cached, book.fileHash) ? cached : null;
20+
}
21+
22+
/// Returns local book bytes, downloading and caching private server media
23+
/// when needed.
24+
Future<Uint8List> ensureBookFileCached(
25+
Book book, {
26+
required LocalBookFileReader readLocalBookFile,
27+
required LocalBookFileWriter writeLocalBookFile,
28+
required MediaDownloader downloadMedia,
29+
}) async {
30+
final cached = await getValidCachedBookFile(book, readLocalBookFile: readLocalBookFile);
31+
if (cached != null) return cached;
32+
33+
final mediaId = book.fileMediaId;
34+
if (mediaId == null || mediaId.isEmpty) {
35+
throw StateError('Book file is not available on this device or server.');
36+
}
37+
38+
final downloaded = await downloadMedia(mediaId);
39+
if (!_matchesExpectedHash(downloaded, book.fileHash)) {
40+
throw StateError('Downloaded book file did not match the expected hash.');
41+
}
42+
43+
await writeLocalBookFile(book.id, _extensionFor(book), downloaded);
44+
return downloaded;
45+
}
46+
47+
String sha256Hex(Uint8List bytes) => sha256.convert(bytes).toString();
48+
49+
bool _matchesExpectedHash(Uint8List bytes, String? expectedHash) {
50+
if (expectedHash == null || expectedHash.isEmpty) return true;
51+
return sha256Hex(bytes) == expectedHash;
52+
}
53+
54+
String _extensionFor(Book book) {
55+
return book.fileFormat?.name ?? 'bin';
56+
}
57+
}

0 commit comments

Comments
 (0)