From ba10bf10cd7fbde5e612c346af7461d0fc36ef74 Mon Sep 17 00:00:00 2001 From: kvmgithub Date: Fri, 22 May 2026 17:19:56 +0200 Subject: [PATCH 1/2] feat: link existing downloads after library sync --- .../rustbridge/ExistingDownloadScanner.kt | 382 ++++++++++++++++++ .../rustbridge/ExpoRustBridgeModule.kt | 46 +++ .../rustbridge/workers/LibrarySyncWorker.kt | 18 + modules/expo-rust-bridge/index.ts | 62 +++ native/rust-core/src/jni_bridge.rs | 15 +- native/rust-core/src/storage/queries.rs | 93 ++++- src/screens/SettingsScreen.tsx | 10 +- src/screens/SimpleAccountScreen.tsx | 26 +- 8 files changed, 645 insertions(+), 7 deletions(-) create mode 100644 modules/expo-rust-bridge/android/src/main/java/expo/modules/rustbridge/ExistingDownloadScanner.kt diff --git a/modules/expo-rust-bridge/android/src/main/java/expo/modules/rustbridge/ExistingDownloadScanner.kt b/modules/expo-rust-bridge/android/src/main/java/expo/modules/rustbridge/ExistingDownloadScanner.kt new file mode 100644 index 0000000..155a281 --- /dev/null +++ b/modules/expo-rust-bridge/android/src/main/java/expo/modules/rustbridge/ExistingDownloadScanner.kt @@ -0,0 +1,382 @@ +package expo.modules.rustbridge + +import android.content.Context +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.text.Normalizer +import java.util.Locale + +object ExistingDownloadScanner { + private const val PREFS_NAME = "app_settings" + private const val PREF_DOWNLOAD_DIRECTORY = "download_directory" + private const val PAGE_SIZE = 500 + private const val MIN_MATCH_SCORE = 700 + private const val MIN_SCORE_MARGIN = 40 + + data class ScanResult( + val filesScanned: Int, + val booksMatched: Int, + val booksLinked: Int, + val booksAlreadyLinked: Int, + val filesUnmatched: Int, + val ambiguousMatches: Int, + val errors: List + ) { + fun toMap(): Map = mapOf( + "files_scanned" to filesScanned, + "books_matched" to booksMatched, + "books_linked" to booksLinked, + "books_already_linked" to booksAlreadyLinked, + "files_unmatched" to filesUnmatched, + "ambiguous_matches" to ambiguousMatches, + "errors" to errors + ) + } + + private data class BookCandidate( + val asin: String, + val title: String, + val authors: List, + val seriesName: String?, + val filePath: String? + ) { + val normalizedTitle: String = normalize(title) + val compactTitle: String = compact(title) + val titleTokens: Set = tokens(title) + val compactAuthors: List = authors.map(::compact).filter { it.length >= 5 } + val compactSeries: String = seriesName?.let(::compact).orEmpty() + } + + private data class AudioCandidate( + val name: String, + val uri: String, + val pathParts: List + ) { + val stem: String = name.substringBeforeLast('.', name) + val searchText: String = normalize((pathParts + name + Uri.decode(uri)).joinToString(" ")) + val compactSearchText: String = compact((pathParts + name + Uri.decode(uri)).joinToString(" ")) + val rawSearchText: String = ((pathParts + name + Uri.decode(uri)).joinToString(" ")).uppercase(Locale.US) + } + + private data class MatchCandidate( + val file: AudioCandidate, + val book: BookCandidate, + val score: Int + ) + + fun saveDownloadDirectory(context: Context, directory: String) { + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .putString(PREF_DOWNLOAD_DIRECTORY, directory) + .apply() + } + + fun getSavedDownloadDirectory(context: Context): String? { + return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .getString(PREF_DOWNLOAD_DIRECTORY, null) + } + + fun scan(context: Context, dbPath: String, downloadDirectory: String): ScanResult { + val errors = mutableListOf() + val books = loadBooks(dbPath, errors) + + if (books.isEmpty()) { + return ScanResult(0, 0, 0, 0, 0, 0, errors) + } + + val files = scanAudioFiles(context, downloadDirectory, errors) + val matchesByAsin = mutableMapOf() + var filesUnmatched = 0 + var ambiguousMatches = 0 + + for (file in files) { + val match = findBestMatch(file, books) + if (match == null) { + filesUnmatched += 1 + continue + } + + if (match.score < 1000 && isAmbiguous(file, books, match)) { + ambiguousMatches += 1 + continue + } + + val existing = matchesByAsin[match.book.asin] + if (existing == null || match.score > existing.score) { + matchesByAsin[match.book.asin] = match + } + } + + var linked = 0 + var alreadyLinked = 0 + + for (match in matchesByAsin.values) { + if (match.book.filePath == match.file.uri) { + alreadyLinked += 1 + continue + } + + if (linkBookToFile(dbPath, match.book, match.file, errors)) { + linked += 1 + } + } + + return ScanResult( + filesScanned = files.size, + booksMatched = matchesByAsin.size, + booksLinked = linked, + booksAlreadyLinked = alreadyLinked, + filesUnmatched = filesUnmatched, + ambiguousMatches = ambiguousMatches, + errors = errors + ) + } + + private fun loadBooks(dbPath: String, errors: MutableList): List { + val books = mutableListOf() + var offset = 0 + var totalCount = Int.MAX_VALUE + + while (offset < totalCount) { + val params = JSONObject().apply { + put("db_path", dbPath) + put("offset", offset) + put("limit", PAGE_SIZE) + put("sort_field", "title") + put("sort_direction", "asc") + } + + val response = JSONObject(ExpoRustBridgeModule.nativeGetBooksWithFilters(params.toString())) + if (!response.optBoolean("success")) { + errors.add(response.optString("error", "Failed to load books for download scan")) + break + } + + val data = response.optJSONObject("data") ?: break + totalCount = data.optInt("total_count", offset) + val pageBooks = data.optJSONArray("books") ?: JSONArray() + if (pageBooks.length() == 0) break + + for (i in 0 until pageBooks.length()) { + val book = pageBooks.optJSONObject(i) ?: continue + val asin = book.optString("audible_product_id") + val title = book.optString("title") + if (asin.isBlank() || title.isBlank()) continue + + books.add( + BookCandidate( + asin = asin, + title = title, + authors = jsonStringArray(book.optJSONArray("authors")), + seriesName = book.optStringOrNull("series_name"), + filePath = book.optStringOrNull("file_path") + ) + ) + } + + offset += pageBooks.length() + } + + return books + } + + private fun scanAudioFiles( + context: Context, + downloadDirectory: String, + errors: MutableList + ): List { + return if (downloadDirectory.startsWith("content://")) { + val rootUri = Uri.parse(downloadDirectory) + val root = DocumentFile.fromTreeUri(context, rootUri) + if (root == null || !root.isDirectory) { + errors.add("Download directory is not accessible") + emptyList() + } else { + val files = mutableListOf() + scanDocumentDirectory(root, listOf(root.name ?: ""), files) + files + } + } else { + val root = File(downloadDirectory.removePrefix("file://")) + if (!root.exists() || !root.isDirectory) { + errors.add("Download directory is not accessible") + emptyList() + } else { + root.walkTopDown() + .filter { it.isFile && isAudioFile(it.name, null) } + .map { file -> + val relative = runCatching { + root.toPath().relativize(file.toPath()).map { it.toString() } + }.getOrDefault(listOf(file.name)) + AudioCandidate(file.name, file.absolutePath, relative) + } + .toList() + } + } + } + + private fun scanDocumentDirectory( + directory: DocumentFile, + pathParts: List, + files: MutableList + ) { + directory.listFiles().forEach { child -> + val name = child.name ?: return@forEach + if (child.isDirectory) { + scanDocumentDirectory(child, pathParts + name, files) + } else if (child.isFile && isAudioFile(name, child.type)) { + files.add(AudioCandidate(name, child.uri.toString(), pathParts + name)) + } + } + } + + private fun findBestMatch(file: AudioCandidate, books: List): MatchCandidate? { + return books + .mapNotNull { book -> + val score = score(file, book) + if (score >= MIN_MATCH_SCORE) MatchCandidate(file, book, score) else null + } + .maxByOrNull { it.score } + } + + private fun isAmbiguous( + file: AudioCandidate, + books: List, + best: MatchCandidate + ): Boolean { + val secondBest = books + .asSequence() + .filter { it.asin != best.book.asin } + .map { score(file, it) } + .filter { it >= MIN_MATCH_SCORE } + .maxOrNull() + + return secondBest != null && best.score - secondBest < MIN_SCORE_MARGIN + } + + private fun score(file: AudioCandidate, book: BookCandidate): Int { + if (book.asin.isNotBlank() && file.rawSearchText.contains(book.asin.uppercase(Locale.US))) { + return 1000 + } + + var score = 0 + val normalizedStem = normalize(file.stem) + + if (book.normalizedTitle.isNotBlank() && normalizedStem == book.normalizedTitle) { + score = maxOf(score, 850) + } + + if (book.normalizedTitle.length >= 8 && file.searchText.contains(book.normalizedTitle)) { + score = maxOf(score, 780) + } + + if (book.compactTitle.length >= 8 && file.compactSearchText.contains(book.compactTitle)) { + score = maxOf(score, 760) + } + + val coverage = tokenCoverage(book.titleTokens, file.searchText) + if (coverage >= 0.9 && book.titleTokens.size >= 2) { + score = maxOf(score, 680) + } else if (coverage >= 0.75 && book.titleTokens.size >= 3) { + score = maxOf(score, 620) + } + + if (score > 0 && book.compactAuthors.any { file.compactSearchText.contains(it) }) { + score += 150 + } + + if (score > 0 && book.compactSeries.length >= 5 && file.compactSearchText.contains(book.compactSeries)) { + score += 80 + } + + return score + } + + private fun tokenCoverage(tokens: Set, text: String): Double { + if (tokens.isEmpty()) return 0.0 + val matched = tokens.count { token -> text.contains(token) } + return matched.toDouble() / tokens.size.toDouble() + } + + private fun linkBookToFile( + dbPath: String, + book: BookCandidate, + file: AudioCandidate, + errors: MutableList + ): Boolean { + val params = JSONObject().apply { + put("db_path", dbPath) + put("asin", book.asin) + put("title", book.title) + put("file_path", file.uri) + } + + val response = JSONObject(ExpoRustBridgeModule.nativeSetBookFilePath(params.toString())) + if (!response.optBoolean("success")) { + errors.add("${book.title}: ${response.optString("error", "Failed to link file")}") + return false + } + + return true + } + + private fun jsonStringArray(array: JSONArray?): List { + if (array == null) return emptyList() + return (0 until array.length()).mapNotNull { index -> + array.optString(index).takeIf { it.isNotBlank() } + } + } + + private fun JSONObject.optStringOrNull(name: String): String? { + if (!has(name) || isNull(name)) return null + return optString(name).takeIf { it.isNotBlank() } + } + + private fun tokens(value: String): Set { + return normalize(value) + .split(' ') + .map { it.trim() } + .filter { it.length >= 3 && it !in STOP_WORDS } + .toSet() + } + + private fun normalize(value: String): String { + val ascii = Normalizer.normalize(value, Normalizer.Form.NFD) + .replace("\\p{Mn}+".toRegex(), "") + + return ascii + .lowercase(Locale.US) + .replace("[^a-z0-9]+".toRegex(), " ") + .trim() + .replace("\\s+".toRegex(), " ") + } + + private fun compact(value: String): String { + return normalize(value).replace(" ", "") + } + + private fun isAudioFile(displayName: String, mimeType: String?): Boolean { + if (mimeType?.startsWith("audio/") == true) { + return true + } + + val lowerName = displayName.lowercase(Locale.US) + return lowerName.endsWith(".m4b") || + lowerName.endsWith(".m4a") || + lowerName.endsWith(".mp4") || + lowerName.endsWith(".mp3") || + lowerName.endsWith(".aac") || + lowerName.endsWith(".flac") || + lowerName.endsWith(".ogg") || + lowerName.endsWith(".opus") || + lowerName.endsWith(".wav") + } + + private val STOP_WORDS = setOf( + "the", "and", "for", "with", "from", "into", "onto", "book", "part", "vol", + "volume", "edition", "unabridged", "audiobook" + ) +} diff --git a/modules/expo-rust-bridge/android/src/main/java/expo/modules/rustbridge/ExpoRustBridgeModule.kt b/modules/expo-rust-bridge/android/src/main/java/expo/modules/rustbridge/ExpoRustBridgeModule.kt index 9fb6d9f..0084c19 100644 --- a/modules/expo-rust-bridge/android/src/main/java/expo/modules/rustbridge/ExpoRustBridgeModule.kt +++ b/modules/expo-rust-bridge/android/src/main/java/expo/modules/rustbridge/ExpoRustBridgeModule.kt @@ -203,6 +203,52 @@ class ExpoRustBridgeModule : Module() { } } + /** + * Persist the user-selected download directory for background sync workers. + */ + Function("setDownloadDirectory") { directory: String -> + try { + val context = appContext.reactContext ?: throw Exception("Context not available") + ExistingDownloadScanner.saveDownloadDirectory(context, directory) + mapOf("success" to true, "data" to mapOf("saved" to true)) + } catch (e: Exception) { + mapOf("success" to false, "error" to e.message) + } + } + + /** + * Read the native copy of the user-selected download directory. + */ + Function("getDownloadDirectory") { + try { + val context = appContext.reactContext ?: throw Exception("Context not available") + mapOf( + "success" to true, + "data" to mapOf("directory" to ExistingDownloadScanner.getSavedDownloadDirectory(context)) + ) + } catch (e: Exception) { + mapOf("success" to false, "error" to e.message) + } + } + + /** + * Scan the download directory for existing audio files and link matches to synced books. + */ + AsyncFunction("scanDownloadDirectory") { dbPath: String, downloadDirectory: String? -> + try { + val context = appContext.reactContext ?: throw Exception("Context not available") + val directory = downloadDirectory + ?: ExistingDownloadScanner.getSavedDownloadDirectory(context) + ?: throw Exception("Download directory is not set") + + ExistingDownloadScanner.saveDownloadDirectory(context, directory) + val scanResult = ExistingDownloadScanner.scan(context, dbPath, directory) + mapOf("success" to true, "data" to scanResult.toMap()) + } catch (e: Exception) { + mapOf("success" to false, "error" to e.message) + } + } + /** * Get paginated list of books from database. * diff --git a/modules/expo-rust-bridge/android/src/main/java/expo/modules/rustbridge/workers/LibrarySyncWorker.kt b/modules/expo-rust-bridge/android/src/main/java/expo/modules/rustbridge/workers/LibrarySyncWorker.kt index a86c7d9..58db52a 100644 --- a/modules/expo-rust-bridge/android/src/main/java/expo/modules/rustbridge/workers/LibrarySyncWorker.kt +++ b/modules/expo-rust-bridge/android/src/main/java/expo/modules/rustbridge/workers/LibrarySyncWorker.kt @@ -9,6 +9,7 @@ import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import expo.modules.rustbridge.AppPaths import expo.modules.rustbridge.ExpoRustBridgeModule +import expo.modules.rustbridge.ExistingDownloadScanner import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.json.JSONObject @@ -147,6 +148,23 @@ class LibrarySyncWorker( } Log.d(TAG, "Library sync complete: $totalItemsSynced items ($totalItemsAdded added, $totalItemsUpdated updated)") + + val downloadDirectory = ExistingDownloadScanner.getSavedDownloadDirectory(applicationContext) + if (!downloadDirectory.isNullOrBlank()) { + try { + val scanResult = ExistingDownloadScanner.scan(applicationContext, dbPath, downloadDirectory) + Log.d( + TAG, + "Existing download scan complete: ${scanResult.booksLinked} linked, " + + "${scanResult.booksAlreadyLinked} already linked, ${scanResult.filesUnmatched} unmatched" + ) + } catch (e: Exception) { + Log.w(TAG, "Existing download scan failed after successful library sync", e) + } + } else { + Log.d(TAG, "No download directory configured; skipping existing download scan") + } + return@withContext Result.success() } catch (e: Exception) { diff --git a/modules/expo-rust-bridge/index.ts b/modules/expo-rust-bridge/index.ts index 465b2a1..d288003 100644 --- a/modules/expo-rust-bridge/index.ts +++ b/modules/expo-rust-bridge/index.ts @@ -228,6 +228,19 @@ export interface SyncStats { has_more: boolean; } +/** + * Existing download directory scan statistics. + */ +export interface DownloadDirectoryScanStats { + files_scanned: number; + books_matched: number; + books_linked: number; + books_already_linked: number; + files_unmatched: number; + ambiguous_matches: number; + errors: string[]; +} + // ---------------------------------------------------------------------------- // Download & Progress Types // ---------------------------------------------------------------------------- @@ -487,6 +500,24 @@ export interface ExpoRustBridgeModule { */ syncLibraryPage(dbPath: string, accountJson: string, page: number): Promise>; + /** + * Save the selected download directory for native background workers. + */ + setDownloadDirectory(directory: string): RustResponse<{ saved: boolean }>; + + /** + * Get the native copy of the selected download directory. + */ + getDownloadDirectory(): RustResponse<{ directory: string | null }>; + + /** + * Scan the download directory and link existing files to synced audiobooks. + */ + scanDownloadDirectory( + dbPath: string, + downloadDirectory?: string | null + ): Promise>; + // -------------------------------------------------------------------------- // Utilities // -------------------------------------------------------------------------- @@ -1364,6 +1395,34 @@ async function syncLibraryPage(dbPath: string, account: Account, page: number): return unwrapResult(response); } +/** + * Save the selected download directory for native background workers. + */ +function setDownloadDirectory(directory: string): void { + const response = NativeModule!.setDownloadDirectory(directory); + unwrapResult(response); +} + +/** + * Get the native copy of the selected download directory. + */ +function getDownloadDirectory(): string | null { + const response = NativeModule!.getDownloadDirectory(); + const data = unwrapResult(response); + return data.directory; +} + +/** + * Scan the download directory and link existing audio files to synced audiobooks. + */ +async function scanDownloadDirectory( + dbPath: string, + downloadDirectory?: string | null +): Promise { + const response = await NativeModule!.scanDownloadDirectory(dbPath, downloadDirectory ?? null); + return unwrapResult(response); +} + /** * Enqueue a download using the persistent download manager. * @@ -2068,6 +2127,9 @@ export { initializeDatabase, syncLibrary, syncLibraryPage, + setDownloadDirectory, + getDownloadDirectory, + scanDownloadDirectory, getBooks, getBooksWithFilters, getAllSeries, diff --git a/native/rust-core/src/jni_bridge.rs b/native/rust-core/src/jni_bridge.rs index 8ccceb7..c00a501 100644 --- a/native/rust-core/src/jni_bridge.rs +++ b/native/rust-core/src/jni_bridge.rs @@ -872,6 +872,8 @@ pub extern "C" fn Java_expo_modules_rustbridge_ExpoRustBridgeModule_nativeGetBoo ) .await?; let total_count = crate::storage::queries::count_books(db.pool()).await?; + let file_paths = + crate::storage::queries::get_completed_download_paths(db.pool()).await?; // Convert BookWithRelations to JSON with arrays for authors/narrators let books_json: Vec = books.iter().map(|book| { @@ -898,7 +900,7 @@ pub extern "C" fn Java_expo_modules_rustbridge_ExpoRustBridgeModule_nativeGetBoo "publisher": book.publisher, "series_name": book.series_name, "series_sequence": book.series_sequence, - "file_path": null, // TODO: Add when download manager implemented + "file_path": file_paths.get(&book.audible_product_id).cloned(), "pdf_url": book.pdf_url, "is_finished": book.is_finished, "is_downloadable": book.is_downloadable, @@ -978,6 +980,12 @@ pub extern "C" fn Java_expo_modules_rustbridge_ExpoRustBridgeModule_nativeGetBoo .await?; if let Some(book) = book { + let file_path = crate::storage::queries::get_book_file_path( + db.pool(), + &book.audible_product_id, + ) + .await?; + let book_json = serde_json::json!({ "id": book.book_id, "audible_product_id": book.audible_product_id, @@ -1001,6 +1009,7 @@ pub extern "C" fn Java_expo_modules_rustbridge_ExpoRustBridgeModule_nativeGetBoo "publisher": book.publisher, "series_name": book.series_name, "series_sequence": book.series_sequence, + "file_path": file_path, "pdf_url": book.pdf_url, "is_finished": book.is_finished, "is_downloadable": book.is_downloadable, @@ -1197,6 +1206,8 @@ pub extern "C" fn Java_expo_modules_rustbridge_ExpoRustBridgeModule_nativeGetBoo let total_count = crate::storage::queries::count_books_with_filters(db.pool(), &query_params) .await?; + let file_paths = + crate::storage::queries::get_completed_download_paths(db.pool()).await?; // Convert BookWithRelations to JSON with arrays for authors/narrators let books_json: Vec = books.iter().map(|book| { @@ -1223,7 +1234,7 @@ pub extern "C" fn Java_expo_modules_rustbridge_ExpoRustBridgeModule_nativeGetBoo "publisher": book.publisher, "series_name": book.series_name, "series_sequence": book.series_sequence, - "file_path": null, + "file_path": file_paths.get(&book.audible_product_id).cloned(), "pdf_url": book.pdf_url, "is_finished": book.is_finished, "is_downloadable": book.is_downloadable, diff --git a/native/rust-core/src/storage/queries.rs b/native/rust-core/src/storage/queries.rs index c93f31f..3493fd6 100644 --- a/native/rust-core/src/storage/queries.rs +++ b/native/rust-core/src/storage/queries.rs @@ -39,6 +39,7 @@ use crate::storage::models::*; use chrono::Utc; use serde::{Deserialize, Serialize}; use sqlx::{Executor, SqlitePool}; +use std::collections::HashMap; // ============================================================================ // BOOK QUERIES @@ -1377,10 +1378,33 @@ pub async fn get_book_file_path(pool: &SqlitePool, asin: &str) -> Result Result> { + let rows: Vec<(String, String)> = sqlx::query_as( + r#" + SELECT dt.asin, dt.output_path + FROM DownloadTasks dt + INNER JOIN ( + SELECT asin, MAX(completed_at) AS completed_at + FROM DownloadTasks + WHERE status = 'completed' AND output_path != '' + GROUP BY asin + ) latest + ON latest.asin = dt.asin + AND latest.completed_at = dt.completed_at + WHERE dt.status = 'completed' AND dt.output_path != '' + "#, + ) + .fetch_all(pool) + .await?; + + Ok(rows.into_iter().collect()) +} + /// Set the file path for a book by creating a manually completed download task. /// /// This allows users to mark a book as downloaded by associating it with an -/// existing audio file on disk. Creates a download task with status "completed". +/// existing audio file on disk. Creates or updates a completed download task. /// /// # Arguments /// * `pool` - Database connection pool @@ -1397,9 +1421,41 @@ pub async fn set_book_file_path( file_path: &str, ) -> Result { use uuid::Uuid; - let task_id = Uuid::new_v4().to_string(); let now = chrono::Utc::now().to_rfc3339(); + let existing_task_id: Option = sqlx::query_scalar( + r#" + SELECT task_id + FROM DownloadTasks + WHERE asin = ? AND status = 'completed' + ORDER BY completed_at DESC + LIMIT 1 + "#, + ) + .bind(asin) + .fetch_optional(pool) + .await?; + + if let Some(task_id) = existing_task_id { + sqlx::query( + r#" + UPDATE DownloadTasks + SET title = ?, output_path = ?, completed_at = ? + WHERE task_id = ? + "#, + ) + .bind(title) + .bind(file_path) + .bind(&now) + .bind(&task_id) + .execute(pool) + .await?; + + return Ok(task_id); + } + + let task_id = Uuid::new_v4().to_string(); + sqlx::query( r#" INSERT INTO DownloadTasks ( @@ -1584,6 +1640,39 @@ mod tests { assert_eq!(contributor_id, contributor_id2); } + #[tokio::test] + async fn test_set_book_file_path_updates_existing_completed_task() { + let db = Database::new_in_memory().await.expect("Failed to create database"); + + let task_id = set_book_file_path(db.pool(), "B012345680", "Test Book", "/books/old.m4b") + .await + .expect("Failed to set file path"); + + let updated_task_id = + set_book_file_path(db.pool(), "B012345680", "Test Book", "/books/new.m4b") + .await + .expect("Failed to update file path"); + + assert_eq!(task_id, updated_task_id); + + let task_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM DownloadTasks WHERE asin = ?") + .bind("B012345680") + .fetch_one(db.pool()) + .await + .expect("Failed to count download tasks"); + assert_eq!(task_count, 1); + + let file_path = get_book_file_path(db.pool(), "B012345680") + .await + .expect("Failed to get file path"); + assert_eq!(file_path.as_deref(), Some("/books/new.m4b")); + + let paths = get_completed_download_paths(db.pool()) + .await + .expect("Failed to get completed paths"); + assert_eq!(paths.get("B012345680").map(String::as_str), Some("/books/new.m4b")); + } + #[tokio::test] async fn test_list_books_with_filters_sorts_by_length() { let db = Database::new_in_memory().await.expect("Failed to create database"); diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index c366c68..db32689 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -73,7 +73,12 @@ export default function SettingsScreen() { SecureStore.getItemAsync(AUTO_TOKEN_REFRESH_KEY), ]); - if (savedPath) setDownloadPath(savedPath); + if (savedPath) { + setDownloadPath(savedPath); + if (Platform.OS === 'android') { + ExpoRustBridge.setDownloadDirectory(savedPath); + } + } if (savedSyncFreq) setSyncFrequency(savedSyncFreq as SyncFrequency); if (savedSyncWifi !== null) setSyncWifiOnly(savedSyncWifi === 'true'); if (savedAutoRefresh !== null) setAutoTokenRefresh(savedAutoRefresh === 'true'); @@ -123,6 +128,9 @@ export default function SettingsScreen() { const selectedUri = selectedDirectory.uri; setDownloadPath(selectedUri); await saveSettings(DOWNLOAD_PATH_KEY, selectedUri); + if (Platform.OS === 'android') { + ExpoRustBridge.setDownloadDirectory(selectedUri); + } Alert.alert('Success', `Download directory updated successfully\n\n${(selectedDirectory as any).name || 'Selected directory'}`); } } catch (error: any) { diff --git a/src/screens/SimpleAccountScreen.tsx b/src/screens/SimpleAccountScreen.tsx index 709bd1d..5c4dec9 100644 --- a/src/screens/SimpleAccountScreen.tsx +++ b/src/screens/SimpleAccountScreen.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { View, Text, Alert, ScrollView } from 'react-native'; +import { View, Text, Alert, ScrollView, Platform } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useFocusEffect } from '@react-navigation/native'; import * as SecureStore from 'expo-secure-store'; @@ -14,6 +14,7 @@ import { saveAccount, getPrimaryAccount, deleteAccount, + scanDownloadDirectory, SyncStats, cancelAllBackgroundTasks, scheduleLibrarySync, @@ -25,6 +26,8 @@ import { useTheme } from '../styles/theme'; import type { Theme } from '../hooks/useStyles'; import { getDatabasePath } from '../utils/appPaths'; +const DOWNLOAD_PATH_KEY = 'download_path'; + export default function SimpleAccountScreen() { const styles = useStyles(createStyles); const { colors, spacing } = useTheme(); @@ -479,15 +482,34 @@ export default function SimpleAccountScreen() { // Update UI with final stats setSyncStats(stats); + + const downloadDir = await SecureStore.getItemAsync(DOWNLOAD_PATH_KEY); + let existingDownloadsLinked = 0; + if (Platform.OS === 'android' && downloadDir) { + try { + const downloadScanStats = await scanDownloadDirectory(dbPath, downloadDir); + existingDownloadsLinked = downloadScanStats.books_linked; + if (downloadScanStats.errors.length > 0) { + console.warn('[SimpleAccountScreen] Existing download scan warnings:', downloadScanStats.errors); + } + } catch (scanError) { + console.warn('[SimpleAccountScreen] Existing download scan failed:', scanError); + } + } + const now = new Date(); setLastSyncDate(now); // Save last sync timestamp await SecureStore.setItemAsync('last_sync_date', now.toISOString()); + const scanSummary = Platform.OS === 'android' && downloadDir + ? `\nExisting downloads linked: ${existingDownloadsLinked}` + : ''; + Alert.alert( 'Sync Complete!', - `Synced: ${stats.total_items} / ${stats.total_library_count}\nAdded: ${stats.books_added}\nUpdated: ${stats.books_updated}` + `Synced: ${stats.total_items} / ${stats.total_library_count}\nAdded: ${stats.books_added}\nUpdated: ${stats.books_updated}${scanSummary}` ); } catch (error: any) { console.error('Sync failed:', error); From 4ed64cdc229a64d1a2b8f74c732ee273db54ce8b Mon Sep 17 00:00:00 2001 From: kvmgithub Date: Fri, 22 May 2026 21:55:05 +0200 Subject: [PATCH 2/2] feat: require download dir and downloaded sort --- .../rustbridge/ExpoRustBridgeModule.kt | 8 +- modules/expo-rust-bridge/index.ts | 18 +- native/rust-core/src/jni_bridge.rs | 30 +++- native/rust-core/src/storage/queries.rs | 166 ++++++++++++++++-- src/screens/LibraryScreen.tsx | 56 +++++- src/screens/SimpleAccountScreen.tsx | 60 ++++++- 6 files changed, 309 insertions(+), 29 deletions(-) diff --git a/modules/expo-rust-bridge/android/src/main/java/expo/modules/rustbridge/ExpoRustBridgeModule.kt b/modules/expo-rust-bridge/android/src/main/java/expo/modules/rustbridge/ExpoRustBridgeModule.kt index 0084c19..e86ef10 100644 --- a/modules/expo-rust-bridge/android/src/main/java/expo/modules/rustbridge/ExpoRustBridgeModule.kt +++ b/modules/expo-rust-bridge/android/src/main/java/expo/modules/rustbridge/ExpoRustBridgeModule.kt @@ -290,8 +290,8 @@ class ExpoRustBridgeModule : Module() { * @param searchQuery Optional search query (searches title, author, narrator) * @param seriesName Optional series filter * @param category Optional category/genre filter - * @param sortField Sort field: "title", "release_date", "date_added", "series", or "length" - * @param sortDirection Sort direction: "asc" or "desc" + * @param sortField Sort field: "title", "release_date", "date_added", "series", "length", or "downloaded" + * @param extras JSON with sort direction, source, and optional downloaded group sort * @return Map with success flag, books array, and total_count */ Function("getBooksWithFilters") { @@ -312,12 +312,14 @@ class ExpoRustBridgeModule : Module() { if (seriesName != null) put("series_name", seriesName) if (category != null) put("category", category) if (sortField != null) put("sort_field", sortField) - // extras is a JSON string with optional sort_direction and source + // extras is a JSON string with optional sort_direction, source, and downloaded group sort if (extras != null) { try { val extrasObj = JSONObject(extras) if (extrasObj.has("sort_direction")) put("sort_direction", extrasObj.getString("sort_direction")) if (extrasObj.has("source")) put("source", extrasObj.getString("source")) + if (extrasObj.has("downloaded_group_sort_field")) put("downloaded_group_sort_field", extrasObj.getString("downloaded_group_sort_field")) + if (extrasObj.has("downloaded_group_sort_direction")) put("downloaded_group_sort_direction", extrasObj.getString("downloaded_group_sort_direction")) } catch (_: Exception) { // If extras is not valid JSON, treat it as sort_direction for backward compat put("sort_direction", extras) diff --git a/modules/expo-rust-bridge/index.ts b/modules/expo-rust-bridge/index.ts index d288003..6d8c01b 100644 --- a/modules/expo-rust-bridge/index.ts +++ b/modules/expo-rust-bridge/index.ts @@ -414,8 +414,8 @@ export interface ExpoRustBridgeModule { * @param searchQuery - Optional search query (searches title, author, narrator) * @param seriesName - Optional series filter * @param category - Optional category/genre filter - * @param sortField - Sort field: "title" | "release_date" | "date_added" | "series" | "length" - * @param sortDirection - Sort direction: "asc" | "desc" + * @param sortField - Sort field: "title" | "release_date" | "date_added" | "series" | "length" | "downloaded" + * @param extras - JSON extras: sort_direction, source, and downloaded group sort values * @returns Array of books and total count */ getBooksWithFilters( @@ -1281,8 +1281,10 @@ function getBooks(dbPath: string, offset: number, limit: number): { books: Book[ * @param searchQuery - Optional search query (searches title, author, narrator) * @param seriesName - Optional series filter * @param category - Optional category/genre filter - * @param sortField - Sort field: "title" | "release_date" | "date_added" | "series" | "length" + * @param sortField - Sort field: "title" | "release_date" | "date_added" | "series" | "length" | "downloaded" * @param sortDirection - Sort direction: "asc" | "desc" + * @param downloadedGroupSortField - Sort used inside downloaded/not-downloaded groups + * @param downloadedGroupSortDirection - Direction used inside downloaded/not-downloaded groups * @returns Books and total count */ function getBooksWithFilters( @@ -1294,14 +1296,18 @@ function getBooksWithFilters( category?: string | null, sortField?: string | null, sortDirection?: string | null, - source?: string | null + source?: string | null, + downloadedGroupSortField?: string | null, + downloadedGroupSortDirection?: string | null ): { books: Book[]; total_count: number } { - // Pack sortDirection and source into extras JSON (Kotlin Function limit: 8 params) + // Pack extra sort/filter values into extras JSON (Kotlin Function limit: 8 params) let extras: string | null = null; - if (sortDirection || source) { + if (sortDirection || source || downloadedGroupSortField || downloadedGroupSortDirection) { const extrasObj: Record = {}; if (sortDirection) extrasObj.sort_direction = sortDirection; if (source) extrasObj.source = source; + if (downloadedGroupSortField) extrasObj.downloaded_group_sort_field = downloadedGroupSortField; + if (downloadedGroupSortDirection) extrasObj.downloaded_group_sort_direction = downloadedGroupSortDirection; extras = JSON.stringify(extrasObj); } diff --git a/native/rust-core/src/jni_bridge.rs b/native/rust-core/src/jni_bridge.rs index c00a501..6041d8d 100644 --- a/native/rust-core/src/jni_bridge.rs +++ b/native/rust-core/src/jni_bridge.rs @@ -1122,8 +1122,10 @@ pub extern "C" fn Java_expo_modules_rustbridge_ExpoRustBridgeModule_nativeSearch /// "search_query": "harry potter", // optional /// "series_name": "Harry Potter", // optional /// "category": "Fantasy", // optional -/// "sort_field": "title", // "title" | "release_date" | "date_added" | "series" | "length" -/// "sort_direction": "asc" // "asc" | "desc" +/// "sort_field": "title", // "title" | "release_date" | "date_added" | "series" | "length" | "downloaded" +/// "sort_direction": "asc", // "asc" | "desc" +/// "downloaded_group_sort_field": "title", +/// "downloaded_group_sort_direction": "asc" /// } /// ``` /// @@ -1156,6 +1158,8 @@ pub extern "C" fn Java_expo_modules_rustbridge_ExpoRustBridgeModule_nativeGetBoo category: Option, sort_field: Option, sort_direction: Option, + downloaded_group_sort_field: Option, + downloaded_group_sort_direction: Option, source: Option, } @@ -1175,6 +1179,8 @@ pub extern "C" fn Java_expo_modules_rustbridge_ExpoRustBridgeModule_nativeGetBoo source: params.source, sort_field: None, sort_direction: None, + downloaded_group_sort_field: None, + downloaded_group_sort_direction: None, limit: params.limit, offset: params.offset, }; @@ -1182,6 +1188,18 @@ pub extern "C" fn Java_expo_modules_rustbridge_ExpoRustBridgeModule_nativeGetBoo // Parse sort field if let Some(field) = params.sort_field { query_params.sort_field = match field.as_str() { + "title" => Some(crate::storage::SortField::Title), + "release_date" => Some(crate::storage::SortField::ReleaseDate), + "date_added" => Some(crate::storage::SortField::DateAdded), + "series" => Some(crate::storage::SortField::Series), + "length" => Some(crate::storage::SortField::Length), + "downloaded" => Some(crate::storage::SortField::Downloaded), + _ => None, + }; + } + + if let Some(field) = params.downloaded_group_sort_field { + query_params.downloaded_group_sort_field = match field.as_str() { "title" => Some(crate::storage::SortField::Title), "release_date" => Some(crate::storage::SortField::ReleaseDate), "date_added" => Some(crate::storage::SortField::DateAdded), @@ -1200,6 +1218,14 @@ pub extern "C" fn Java_expo_modules_rustbridge_ExpoRustBridgeModule_nativeGetBoo }; } + if let Some(dir) = params.downloaded_group_sort_direction { + query_params.downloaded_group_sort_direction = match dir.as_str() { + "asc" => Some(crate::storage::SortDirection::Asc), + "desc" => Some(crate::storage::SortDirection::Desc), + _ => None, + }; + } + let books = crate::storage::queries::list_books_with_filters(db.pool(), &query_params) .await?; diff --git a/native/rust-core/src/storage/queries.rs b/native/rust-core/src/storage/queries.rs index 3493fd6..18e78df 100644 --- a/native/rust-core/src/storage/queries.rs +++ b/native/rust-core/src/storage/queries.rs @@ -434,6 +434,7 @@ pub enum SortField { DateAdded, Series, Length, + Downloaded, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -442,6 +443,37 @@ pub enum SortDirection { Desc, } +fn downloaded_status_order_expression(direction: SortDirection) -> &'static str { + match direction { + SortDirection::Asc => { + "CASE WHEN completed_downloads.asin IS NULL THEN 0 ELSE 1 END ASC" + }, + SortDirection::Desc => { + "CASE WHEN completed_downloads.asin IS NULL THEN 0 ELSE 1 END DESC" + }, + } +} + +fn grouped_order_expression(field: SortField, direction: SortDirection) -> &'static str { + match (field, direction) { + (SortField::Title, SortDirection::Asc) => "b.title ASC", + (SortField::Title, SortDirection::Desc) => "b.title DESC", + (SortField::ReleaseDate, SortDirection::Asc) => "b.date_published ASC, b.title ASC", + (SortField::ReleaseDate, SortDirection::Desc) => "b.date_published DESC, b.title ASC", + (SortField::DateAdded, SortDirection::Asc) => "lb.date_added ASC, b.title ASC", + (SortField::DateAdded, SortDirection::Desc) => "lb.date_added DESC, b.title ASC", + (SortField::Length, SortDirection::Asc) => "b.length_in_minutes ASC, b.title ASC", + (SortField::Length, SortDirection::Desc) => "b.length_in_minutes DESC, b.title ASC", + (SortField::Series, SortDirection::Asc) => { + "CASE WHEN book_series_first.series_name IS NULL THEN 1 ELSE 0 END, book_series_first.series_name ASC, book_series_first.series_sequence ASC, b.title ASC" + }, + (SortField::Series, SortDirection::Desc) => { + "CASE WHEN book_series_first.series_name IS NULL THEN 1 ELSE 0 END, book_series_first.series_name DESC, book_series_first.series_sequence DESC, b.title ASC" + }, + (SortField::Downloaded, _) => "b.title ASC", + } +} + /// Filter and search parameters for book queries #[derive(Debug, Clone, Default)] pub struct BookQueryParams { @@ -451,6 +483,8 @@ pub struct BookQueryParams { pub source: Option, // Filter by source (audible, librivox) pub sort_field: Option, pub sort_direction: Option, + pub downloaded_group_sort_field: Option, + pub downloaded_group_sort_direction: Option, pub limit: i64, pub offset: i64, } @@ -508,21 +542,34 @@ pub async fn list_books_with_filters( // Build ORDER BY clause let order_clause = match (params.sort_field, params.sort_direction) { - (Some(SortField::Title), Some(SortDirection::Asc)) => "ORDER BY b.title ASC", - (Some(SortField::Title), Some(SortDirection::Desc)) => "ORDER BY b.title DESC", - (Some(SortField::ReleaseDate), Some(SortDirection::Asc)) => "ORDER BY b.date_published ASC", - (Some(SortField::ReleaseDate), Some(SortDirection::Desc)) => "ORDER BY b.date_published DESC", - (Some(SortField::DateAdded), Some(SortDirection::Asc)) => "ORDER BY lb.date_added ASC", - (Some(SortField::DateAdded), Some(SortDirection::Desc)) => "ORDER BY lb.date_added DESC", - (Some(SortField::Length), Some(SortDirection::Asc)) => "ORDER BY b.length_in_minutes ASC, b.title ASC", - (Some(SortField::Length), Some(SortDirection::Desc)) => "ORDER BY b.length_in_minutes DESC, b.title ASC", + (Some(SortField::Title), Some(SortDirection::Asc)) => "ORDER BY b.title ASC".to_string(), + (Some(SortField::Title), Some(SortDirection::Desc)) => "ORDER BY b.title DESC".to_string(), + (Some(SortField::ReleaseDate), Some(SortDirection::Asc)) => "ORDER BY b.date_published ASC".to_string(), + (Some(SortField::ReleaseDate), Some(SortDirection::Desc)) => "ORDER BY b.date_published DESC".to_string(), + (Some(SortField::DateAdded), Some(SortDirection::Asc)) => "ORDER BY lb.date_added ASC".to_string(), + (Some(SortField::DateAdded), Some(SortDirection::Desc)) => "ORDER BY lb.date_added DESC".to_string(), + (Some(SortField::Length), Some(SortDirection::Asc)) => "ORDER BY b.length_in_minutes ASC, b.title ASC".to_string(), + (Some(SortField::Length), Some(SortDirection::Desc)) => "ORDER BY b.length_in_minutes DESC, b.title ASC".to_string(), + (Some(SortField::Downloaded), direction) => { + let group_direction = direction.unwrap_or(SortDirection::Desc); + let field_direction = params + .downloaded_group_sort_direction + .unwrap_or(SortDirection::Asc); + let field = params.downloaded_group_sort_field.unwrap_or(SortField::Title); + + format!( + "ORDER BY {}, {}", + downloaded_status_order_expression(group_direction), + grouped_order_expression(field, field_direction) + ) + }, (Some(SortField::Series), Some(SortDirection::Asc)) => { - "ORDER BY CASE WHEN book_series_first.series_name IS NULL THEN 1 ELSE 0 END, book_series_first.series_name ASC, book_series_first.series_sequence ASC" + "ORDER BY CASE WHEN book_series_first.series_name IS NULL THEN 1 ELSE 0 END, book_series_first.series_name ASC, book_series_first.series_sequence ASC".to_string() }, (Some(SortField::Series), Some(SortDirection::Desc)) => { - "ORDER BY CASE WHEN book_series_first.series_name IS NULL THEN 1 ELSE 0 END, book_series_first.series_name DESC, book_series_first.series_sequence DESC" + "ORDER BY CASE WHEN book_series_first.series_name IS NULL THEN 1 ELSE 0 END, book_series_first.series_name DESC, book_series_first.series_sequence DESC".to_string() }, - _ => "ORDER BY b.title ASC", // Default + _ => "ORDER BY b.title ASC".to_string(), // Default }; // Build complete query @@ -568,6 +615,12 @@ pub async fn list_books_with_filters( SELECT book_id, series_name, series_sequence FROM book_series WHERE rn = 1 + ), + completed_downloads AS ( + SELECT asin, MAX(completed_at) as completed_at + FROM DownloadTasks + WHERE status = 'completed' AND output_path != '' + GROUP BY asin ) SELECT b.book_id, @@ -609,6 +662,7 @@ pub async fn list_books_with_filters( LEFT JOIN book_narrators ON b.book_id = book_narrators.book_id LEFT JOIN book_publishers ON b.book_id = book_publishers.book_id LEFT JOIN book_series_first ON b.book_id = book_series_first.book_id + LEFT JOIN completed_downloads ON completed_downloads.asin = b.audible_product_id {} {} LIMIT ? OFFSET ? @@ -1733,4 +1787,94 @@ mod tests { vec!["Long Book", "Medium Book", "Short Book"] ); } + + #[tokio::test] + async fn test_list_books_with_filters_sorts_by_downloaded_status() { + let db = Database::new_in_memory() + .await + .expect("Failed to create database"); + + let books = [ + ("B000000001", "Missing Long", 300), + ("B000000002", "Downloaded Short", 30), + ("B000000003", "Missing Short", 60), + ("B000000004", "Downloaded Long", 240), + ]; + + for (asin, title, length_in_minutes) in books { + let mut book = NewBook::new(asin.to_string(), title.to_string(), "us".to_string()); + book.length_in_minutes = length_in_minutes; + + let book_id = insert_book(db.pool(), &book) + .await + .expect("Failed to insert book"); + + insert_library_book( + db.pool(), + &NewLibraryBook { + book_id, + account: "test@example.com".to_string(), + }, + ) + .await + .expect("Failed to insert library book"); + } + + set_book_file_path( + db.pool(), + "B000000002", + "Downloaded Short", + "/books/downloaded-short.m4b", + ) + .await + .expect("Failed to mark book downloaded"); + + set_book_file_path( + db.pool(), + "B000000004", + "Downloaded Long", + "/books/downloaded-long.m4b", + ) + .await + .expect("Failed to mark book downloaded"); + + let params = BookQueryParams { + sort_field: Some(SortField::Downloaded), + sort_direction: Some(SortDirection::Desc), + downloaded_group_sort_field: Some(SortField::Length), + downloaded_group_sort_direction: Some(SortDirection::Asc), + limit: 10, + offset: 0, + ..Default::default() + }; + + let downloaded_first = list_books_with_filters(db.pool(), ¶ms) + .await + .expect("Failed to list books"); + + assert_eq!( + downloaded_first + .iter() + .map(|book| book.title.as_str()) + .collect::>(), + vec!["Downloaded Short", "Downloaded Long", "Missing Short", "Missing Long"] + ); + + let params = BookQueryParams { + sort_direction: Some(SortDirection::Asc), + ..params + }; + + let downloaded_last = list_books_with_filters(db.pool(), ¶ms) + .await + .expect("Failed to list books"); + + assert_eq!( + downloaded_last + .iter() + .map(|book| book.title.as_str()) + .collect::>(), + vec!["Missing Short", "Missing Long", "Downloaded Short", "Downloaded Long"] + ); + } } diff --git a/src/screens/LibraryScreen.tsx b/src/screens/LibraryScreen.tsx index 39e8cf6..e395f36 100644 --- a/src/screens/LibraryScreen.tsx +++ b/src/screens/LibraryScreen.tsx @@ -46,7 +46,8 @@ import { const DOWNLOAD_PATH_KEY = 'download_path'; const LIBRARY_PREFS_KEY = 'library_preferences'; -type SortField = 'title' | 'release_date' | 'date_added' | 'series' | 'length'; +type SortField = 'title' | 'release_date' | 'date_added' | 'series' | 'length' | 'downloaded'; +type BookSortField = Exclude; type SortDirection = 'asc' | 'desc'; type SourceFilter = 'all' | 'audible' | 'librivox'; type IoniconName = React.ComponentProps['name']; @@ -62,6 +63,8 @@ const EXPORT_FORMAT_OPTIONS: Array<{ format: LibraryExportFormat; label: string; interface LibraryPreferences { sortField: SortField; sortDirection: SortDirection; + downloadedGroupSortField?: BookSortField; + downloadedGroupSortDirection?: SortDirection; sourceFilter?: SourceFilter; } @@ -85,6 +88,8 @@ export default function LibraryScreen() { const [searchQuery, setSearchQuery] = useState(''); const [sortField, setSortField] = useState('title'); const [sortDirection, setSortDirection] = useState('asc'); + const [downloadedGroupSortField, setDownloadedGroupSortField] = useState('title'); + const [downloadedGroupSortDirection, setDownloadedGroupSortDirection] = useState('asc'); const [selectedSeries, setSelectedSeries] = useState(null); const [selectedCategory, setSelectedCategory] = useState(null); const [sourceFilter, setSourceFilter] = useState('all'); @@ -137,14 +142,14 @@ export default function LibraryScreen() { clearTimeout(searchTimeout.current); } }; - }, [searchQuery, sortField, sortDirection, selectedSeries, selectedCategory, sourceFilter]); + }, [searchQuery, sortField, sortDirection, downloadedGroupSortField, downloadedGroupSortDirection, selectedSeries, selectedCategory, sourceFilter]); // Reload books when tab is focused useFocusEffect( React.useCallback(() => { console.log('[LibraryScreen] Tab focused, reloading books...'); loadBooks(true); - }, [searchQuery, sortField, sortDirection, selectedSeries, selectedCategory, sourceFilter]) + }, [searchQuery, sortField, sortDirection, downloadedGroupSortField, downloadedGroupSortDirection, selectedSeries, selectedCategory, sourceFilter]) ); // Poll for download progress @@ -183,6 +188,10 @@ export default function LibraryScreen() { const prefs: LibraryPreferences = JSON.parse(prefsJson); setSortField(prefs.sortField); setSortDirection(prefs.sortDirection); + const groupField = prefs.downloadedGroupSortField || (prefs.sortField !== 'downloaded' ? prefs.sortField : 'title'); + const groupDirection = prefs.downloadedGroupSortDirection || (prefs.sortField !== 'downloaded' ? prefs.sortDirection : 'asc'); + setDownloadedGroupSortField(groupField); + setDownloadedGroupSortDirection(groupDirection); if (prefs.sourceFilter) setSourceFilter(prefs.sourceFilter); } } catch (error) { @@ -190,11 +199,18 @@ export default function LibraryScreen() { } }; - const savePreferences = async (field: SortField, direction: SortDirection) => { + const savePreferences = async ( + field: SortField, + direction: SortDirection, + groupField: BookSortField = downloadedGroupSortField, + groupDirection: SortDirection = downloadedGroupSortDirection + ) => { try { const prefs: LibraryPreferences = { sortField: field, sortDirection: direction, + downloadedGroupSortField: groupField, + downloadedGroupSortDirection: groupDirection, }; await SecureStore.setItemAsync(LIBRARY_PREFS_KEY, JSON.stringify(prefs)); } catch (error) { @@ -248,6 +264,8 @@ export default function LibraryScreen() { sortDirection, selectedSeries, selectedCategory, + downloadedGroupSortField, + downloadedGroupSortDirection, }); const response = getBooksWithFilters( @@ -259,7 +277,9 @@ export default function LibraryScreen() { selectedCategory || null, sortField, sortDirection, - sourceFilter === 'all' ? null : sourceFilter + sourceFilter === 'all' ? null : sourceFilter, + sortField === 'downloaded' ? downloadedGroupSortField : null, + sortField === 'downloaded' ? downloadedGroupSortDirection : null ); console.log('[LibraryScreen] Loaded books:', response.books.length, 'of', response.total_count); @@ -301,9 +321,18 @@ export default function LibraryScreen() { }; const handleSortChange = (field: SortField, direction: SortDirection) => { + let nextGroupField = downloadedGroupSortField; + let nextGroupDirection = downloadedGroupSortDirection; + if (field !== 'downloaded') { + nextGroupField = field; + nextGroupDirection = direction; + setDownloadedGroupSortField(nextGroupField); + setDownloadedGroupSortDirection(nextGroupDirection); + } + setSortField(field); setSortDirection(direction); - savePreferences(field, direction); + savePreferences(field, direction, nextGroupField, nextGroupDirection); setShowSortModal(false); }; @@ -989,8 +1018,13 @@ export default function LibraryScreen() { date_added: 'Date Added', series: 'Series', length: 'Length', + downloaded: 'Downloaded', }; const arrow = sortDirection === 'asc' ? '↑' : '↓'; + if (sortField === 'downloaded') { + const groupArrow = downloadedGroupSortDirection === 'asc' ? '↑' : '↓'; + return `Downloaded ${arrow} (${fieldLabels[downloadedGroupSortField]} ${groupArrow})`; + } return `${fieldLabels[sortField]} ${arrow}`; }; @@ -1339,6 +1373,16 @@ export default function LibraryScreen() { {sortField === 'length' && } + handleSortChange('downloaded', sortField === 'downloaded' && sortDirection === 'desc' ? 'asc' : 'desc')} + > + + Downloaded {sortField === 'downloaded' && (sortDirection === 'asc' ? '↑' : '↓')} + + {sortField === 'downloaded' && } + + setShowSortModal(false)} diff --git a/src/screens/SimpleAccountScreen.tsx b/src/screens/SimpleAccountScreen.tsx index 5c4dec9..2326369 100644 --- a/src/screens/SimpleAccountScreen.tsx +++ b/src/screens/SimpleAccountScreen.tsx @@ -3,6 +3,7 @@ import { View, Text, Alert, ScrollView, Platform } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useFocusEffect } from '@react-navigation/native'; import * as SecureStore from 'expo-secure-store'; +import { Directory, Paths } from 'expo-file-system'; import LoginScreen from './LoginScreen'; import Button from '../components/Button'; import { @@ -15,6 +16,7 @@ import { getPrimaryAccount, deleteAccount, scanDownloadDirectory, + setDownloadDirectory, SyncStats, cancelAllBackgroundTasks, scheduleLibrarySync, @@ -414,11 +416,68 @@ export default function SimpleAccountScreen() { } }; + const promptForDownloadDirectory = (): Promise => { + return new Promise(resolve => { + let settled = false; + const finish = (value: boolean) => { + if (!settled) { + settled = true; + resolve(value); + } + }; + + Alert.alert( + 'Download Directory Required', + 'Choose a download directory before syncing your library.', + [ + { text: 'Cancel', style: 'cancel', onPress: () => finish(false) }, + { text: 'Choose', onPress: () => finish(true) }, + ], + { cancelable: true, onDismiss: () => finish(false) } + ); + }); + }; + + const ensureDownloadDirectory = async (): Promise => { + try { + const savedPath = await SecureStore.getItemAsync(DOWNLOAD_PATH_KEY); + if (savedPath) { + if (Platform.OS === 'android') { + setDownloadDirectory(savedPath); + } + return savedPath; + } + + const shouldChoose = await promptForDownloadDirectory(); + if (!shouldChoose) return null; + + const selectedDirectory = await Directory.pickDirectoryAsync( + Platform.OS === 'android' ? undefined : Paths.document?.uri + ); + + if (!selectedDirectory?.uri) return null; + + await SecureStore.setItemAsync(DOWNLOAD_PATH_KEY, selectedDirectory.uri); + if (Platform.OS === 'android') { + setDownloadDirectory(selectedDirectory.uri); + } + + return selectedDirectory.uri; + } catch (error: any) { + console.error('[SimpleAccountScreen] Directory picker error:', error); + Alert.alert('Download Directory Required', error.message || 'Failed to select directory'); + return null; + } + }; + const handleSyncLibrary = async () => { console.log('========== SYNC LIBRARY BUTTON PRESSED =========='); if (!account) return; + const downloadDir = await ensureDownloadDirectory(); + if (!downloadDir) return; + try { setIsSyncing(true); @@ -483,7 +542,6 @@ export default function SimpleAccountScreen() { // Update UI with final stats setSyncStats(stats); - const downloadDir = await SecureStore.getItemAsync(DOWNLOAD_PATH_KEY); let existingDownloadsLinked = 0; if (Platform.OS === 'android' && downloadDir) { try {