Support opds (#133)

* Add OPDS catalog support for book discovery and downloading

* Implement OPDS search, improved book discovery, and enhanced UI details.

* Improve OPDS search and catalog management

* Add authentication support and extended metadata to the OPDS reader.

* Add support for OPDS 2.0 (JSON) feeds

* Enhance OPDS book downloading with progress tracking and multi-format support.

* Enhance OPDS book management and UI

* Add support for OPDS-PSE (Page Streaming Extension) streaming

* Improve OPDS streaming and catalog management
This commit is contained in:
Aryan 2026-03-30 21:42:06 +05:30 committed by GitHub
parent 355664fbcc
commit 15264a31ae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 2208 additions and 33 deletions

View file

@ -455,6 +455,46 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
fun streamOpdsBook(
bookId: String,
title: String,
urlTemplate: String,
pageCount: Int,
catalogId: String?
) {
val encodedUrl = Uri.encode(urlTemplate)
val safeId = Uri.encode(bookId)
val catId = catalogId?.let { "&catalogId=${Uri.encode(it)}" } ?: ""
val uriString = "opds-pse://stream?id=$safeId&count=$pageCount&url=$encodedUrl$catId"
openBook(uriString.toUri(), bookId, FileType.CBZ, title)
}
fun deleteStreamedBooksForCatalog(catalogId: String) {
viewModelScope.launch(Dispatchers.IO) {
val filesToDelete = recentFilesRepository.getAllFilesForSync().filter {
it.uriString?.contains("catalogId=$catalogId") == true
}
if (filesToDelete.isNotEmpty()) {
val ids = filesToDelete.map { it.bookId }
ids.forEach { bookId ->
pdfTextRepository.clearBookText(bookId)
clearImportedFileCache(bookId)
try {
val cacheDir = File(appContext.cacheDir, "opds_stream_${bookId.hashCode()}")
if (cacheDir.exists()) cacheDir.deleteRecursively()
} catch (e: Exception) {
Timber.e(e, "Failed to clean stream cache for $bookId")
}
}
recentFilesRepository.deleteFilePermanently(ids)
withContext(Dispatchers.Main) {
showBanner("Removed ${filesToDelete.size} streaming books.")
}
}
}
}
private val _reviewRequestEvent = Channel<Unit>(Channel.BUFFERED)
val reviewRequestEvent = _reviewRequestEvent.receiveAsFlow()
private var hasRequestedReviewInThisSession = false
@ -1169,6 +1209,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
private fun uploadSingleBookMetadata(book: RecentFileItem) {
if (!uiState.value.isSyncEnabled) return
if (book.uriString?.startsWith("opds-pse") == true) {
Timber.d("Skipping metadata sync for OPDS stream book: ${book.displayName}")
return
}
if (book.sourceFolderUri != null) {
Timber.d("Skipping metadata sync for local folder book: ${book.displayName}")
return
@ -2027,11 +2072,12 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
val localBooks = withContext(Dispatchers.IO) {
val allFiles = recentFilesRepository.getAllFilesForSync()
if (_internalState.value.isFolderSyncEnabled) {
val filtered = if (_internalState.value.isFolderSyncEnabled) {
allFiles
} else {
allFiles.filter { it.sourceFolderUri == null }
}
filtered.filterNot { it.uriString?.startsWith("opds-pse") == true }
}
val localShelfNames = prefs.getStringSet(KEY_SHELVES, emptySet()).orEmpty()
@ -2424,7 +2470,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
if (coverBitmap != null) {
coverPath = recentFilesRepository.saveCoverToCache(coverBitmap, uri)
}
} else if (type == FileType.CBZ || type == FileType.CBR || type == FileType.CB7) {
} else if (uri.scheme != "opds-pse" && (type == FileType.CBZ || type == FileType.CBR || type == FileType.CB7)) {
var cacheFile: File? = null
try {
cacheFile = File(appContext.cacheDir, "temp_archive_cover_${System.currentTimeMillis()}.${type.name.lowercase()}")
@ -2524,6 +2570,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val oneHourAgo = System.currentTimeMillis() - TimeUnit.HOURS.toMillis(1)
val allDbIds = recentFilesRepository.getAllFilesForSync().map { it.bookId }.toSet()
val validStreamHashes = allDbIds.map { it.hashCode().toString() }.toSet()
cacheDir.listFiles()?.forEach { file ->
val name = file.name
@ -2538,6 +2585,12 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val deleted = file.deleteRecursively()
if (deleted) Timber.d("Sweeper cleaned orphaned extracted cache for: $bookId")
}
} else if (name.startsWith("opds_stream_")) {
val bookIdHash = name.removePrefix("opds_stream_")
if (bookIdHash !in validStreamHashes) {
val deleted = if (file.isDirectory) file.deleteRecursively() else file.delete()
if (deleted) Timber.d("Sweeper cleaned orphaned OPDS stream cache for hash: $bookIdHash")
}
}
}
val legacyExtractedDir = File(cacheDir, "extracted_epubs")
@ -2837,20 +2890,22 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
Timber.tag("FileOpenPerf")
.d("[$bookId] openBook START | type=$type | displayName=$originalDisplayName")
try {
val cursor = appContext.contentResolver.query(uri, null, null, null, null)
cursor?.use {
if (it.moveToFirst()) {
val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE)
val nameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME)
val size = if (sizeIndex != -1) it.getLong(sizeIndex) else -1L
val name = if (nameIndex != -1) it.getString(nameIndex) else "unknown"
Timber.tag("FileOpenPerf")
.d("[$bookId] File details | name=$name | size=${size} bytes | sizeMB=${size / (1024.0 * 1024)}")
if (uri.scheme != "opds-pse") {
try {
val cursor = appContext.contentResolver.query(uri, null, null, null, null)
cursor?.use {
if (it.moveToFirst()) {
val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE)
val nameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME)
val size = if (sizeIndex != -1) it.getLong(sizeIndex) else -1L
val name = if (nameIndex != -1) it.getString(nameIndex) else "unknown"
Timber.tag("FileOpenPerf")
.d("[$bookId] File details | name=$name | size=${size} bytes | sizeMB=${size / (1024.0 * 1024)}")
}
}
} catch (e: Exception) {
Timber.tag("FileOpenPerf").e(e, "[$bookId] Failed to get file details")
}
} catch (e: Exception) {
Timber.tag("FileOpenPerf").e(e, "[$bookId] Failed to get file details")
}
viewModelScope.launch {