feat: improve library and audiobook workflow

This commit is contained in:
Курнат Андрей
2026-09-25 06:17:13 +03:00
parent a4d139009f
commit fadcf1da22
70 changed files with 2683 additions and 733 deletions
+3
View File
@@ -4,6 +4,9 @@
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
# Android / Gradle # Android / Gradle
.kotlin/
signing/*.jks
signing/*-info.txt
.gradle/ .gradle/
build/ build/
**/build/ **/build/
+5 -10
View File
@@ -1,13 +1,7 @@
plugins { plugins {
id("com.android.application") id("com.android.application")
id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.android")
} }
val audiobookApiTokenFile = file("${System.getProperty("user.home")}/.aletheia/audiobook-api-token.txt")
val audiobookApiToken = if (audiobookApiTokenFile.isFile) {
audiobookApiTokenFile.readText().trim()
} else {
""
}
fun quotedBuildConfig(value: String): String = fun quotedBuildConfig(value: String): String =
"\"${value.replace("\\", "\\\\").replace("\"", "\\\"")}\"" "\"${value.replace("\\", "\\\\").replace("\"", "\\\"")}\""
@@ -19,15 +13,14 @@ android {
applicationId = "com.aletheia.app" applicationId = "com.aletheia.app"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 50 versionCode = 55
versionName = "2.39" versionName = "2.44"
buildConfigField( buildConfigField(
"String", "String",
"AUDIOBOOK_API_BASE_URL", "AUDIOBOOK_API_BASE_URL",
quotedBuildConfig("https://argus.kusoft.xyz/aletheia-tts/") quotedBuildConfig("https://argus.kusoft.xyz/aletheia-tts/")
) )
buildConfigField("String", "AUDIOBOOK_API_TOKEN", quotedBuildConfig(audiobookApiToken))
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {
@@ -76,6 +69,8 @@ android {
} }
dependencies { dependencies {
implementation("androidx.media3:media3-exoplayer:1.9.3")
implementation("androidx.media3:media3-session:1.9.3")
implementation("androidx.core:core-ktx:1.15.0") implementation("androidx.core:core-ktx:1.15.0")
implementation("androidx.appcompat:appcompat:1.7.0") implementation("androidx.appcompat:appcompat:1.7.0")
implementation("com.google.android.material:material:1.12.0") implementation("com.google.android.material:material:1.12.0")
@@ -0,0 +1,93 @@
package com.aletheia.app.data
import android.content.ComponentName
import android.content.Intent
import android.os.Bundle
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.session.MediaController
import androidx.media3.session.SessionCommand
import androidx.media3.session.SessionToken
import androidx.test.core.app.ActivityScenario
import androidx.test.platform.app.InstrumentationRegistry
import com.aletheia.app.AletheiaApplication
import com.aletheia.app.audiobook.AudioPlaybackService
import com.aletheia.app.model.AudioBook
import com.aletheia.app.model.Book
import com.aletheia.app.ui.main.MainActivity
import java.io.File
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.concurrent.TimeUnit
import org.junit.Assert.*
import org.junit.Test
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
class AudioRegressionTest {
@Test fun oldCacheAudioMovesToPermanentStorageWithoutLosingPosition() {
val app = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as AletheiaApplication
val sourceId = app.databaseHelper.saveBook(Book(title = "Migration fixture"))
val old = File(app.cacheDir, "audiobooks/migration-$sourceId.m4a").apply { parentFile!!.mkdirs(); writeBytes(byteArrayOf(1, 2, 3)) }
val id = app.databaseHelper.saveAudioBook(AudioBook(sourceBookId = sourceId, title = "Migration", author = "", status = AudioBook.Status.READY, filePath = old.path, positionMs = 3210))
try {
val migrated = requireNotNull(app.audioBookRepository.getById(id))
assertTrue(File(migrated.filePath).parentFile == File(app.filesDir, "Audiobooks"))
assertArrayEquals(byteArrayOf(1, 2, 3), File(migrated.filePath).readBytes())
assertFalse(old.exists())
assertEquals(3210L, migrated.positionMs)
} finally {
app.audioBookRepository.getById(id)?.let(app.audioBookRepository::delete)
app.databaseHelper.getBookById(sourceId)?.let(app.databaseHelper::deleteBook)
}
}
@Test fun sleepTimerStopsServicePlaybackWithScreenOff() {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val context = instrumentation.targetContext
val audio = File(context.cacheDir, "sleep-timer-test.wav")
val sampleBytes = 8000 * 2 * 2
val bytes = ByteBuffer.allocate(44 + sampleBytes).order(ByteOrder.LITTLE_ENDIAN)
bytes.put("RIFF".toByteArray()).putInt(36 + sampleBytes).put("WAVEfmt ".toByteArray())
.putInt(16).putShort(1).putShort(1).putInt(8000).putInt(16000).putShort(2).putShort(16)
.put("data".toByteArray()).putInt(sampleBytes)
audio.writeBytes(bytes.array())
ActivityScenario.launch(MainActivity::class.java).use {
val future = MediaController.Builder(context, SessionToken(context, ComponentName(context, AudioPlaybackService::class.java))).buildAsync()
val player = future.get(20, TimeUnit.SECONDS)
try {
instrumentation.runOnMainSync {
player.setMediaItem(MediaItem.fromUri(android.net.Uri.fromFile(audio)))
player.repeatMode = Player.REPEAT_MODE_ONE
player.prepare()
player.play()
}
await(10000) { player.isPlaying }
instrumentation.runOnMainSync {
player.sendCustomCommand(SessionCommand(AudioPlaybackService.SLEEP_COMMAND, Bundle.EMPTY), Bundle().apply { putInt("minutes", 1) })
}
await(5000) { player.sessionExtras.getLong(AudioPlaybackService.SLEEP_DEADLINE) > 0 }
instrumentation.uiAutomation.executeShellCommand("input keyevent KEYCODE_SLEEP").close()
Thread.sleep(1500)
instrumentation.runOnMainSync { assertTrue("Playback should continue in background", player.isPlaying) }
await(65000) { !player.isPlaying && player.sessionExtras.getLong(AudioPlaybackService.SLEEP_DEADLINE) == 0L }
} finally {
instrumentation.uiAutomation.executeShellCommand("input keyevent KEYCODE_WAKEUP").close()
instrumentation.runOnMainSync { player.stop(); player.clearMediaItems(); MediaController.releaseFuture(future) }
context.stopService(Intent(context, AudioPlaybackService::class.java))
audio.delete()
}
}
}
private fun await(timeoutMs: Long, check: () -> Boolean) {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val end = android.os.SystemClock.elapsedRealtime() + timeoutMs
do {
var ready = false
instrumentation.runOnMainSync { ready = check() }
if (ready) return
Thread.sleep(100)
} while (android.os.SystemClock.elapsedRealtime() < end)
fail("Playback state timed out after $timeoutMs ms")
}
}
@@ -0,0 +1,76 @@
package com.aletheia.app.data
import androidx.test.platform.app.InstrumentationRegistry
import com.aletheia.app.model.Book
import org.junit.Assert.*
import org.junit.Test
import java.util.UUID
class DatabaseRegressionTest {
@Test fun version8UpgradePreservesLegacyRowsAndReadingPosition() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val name = "migration-${UUID.randomUUID()}.db"
// Original books schema from release 2.39 (database version 8).
context.openOrCreateDatabase(name, 0, null).use { old ->
old.execSQL("""CREATE TABLE books (
id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, author TEXT NOT NULL,
file_path TEXT NOT NULL, file_name TEXT NOT NULL, format TEXT NOT NULL,
cover_image BLOB, reading_progress REAL NOT NULL DEFAULT 0, last_cfi TEXT,
last_chapter TEXT, locations TEXT, date_added INTEGER NOT NULL, last_read INTEGER NOT NULL,
total_pages INTEGER NOT NULL DEFAULT 0, current_page INTEGER NOT NULL DEFAULT 0, remote_id TEXT)""")
repeat(2) { old.execSQL("INSERT INTO books(title,author,file_path,file_name,format,date_added,last_read,remote_id,reading_progress,last_cfi) VALUES('Legacy','Author','','','epub',1,2,'42',0.75,'old-cfi')") }
old.version = 8
}
val upgraded = AppDatabaseHelper(context, name)
try {
val books = upgraded.getAllBooks()
assertEquals(2, books.size)
books.forEach { assertNull(it.catalogSource); assertEquals(0.75, it.readingProgress, 0.0); assertEquals("old-cfi", it.lastCfi) }
assertEquals(9, upgraded.readableDatabase.version)
} finally { upgraded.close(); context.deleteDatabase(name) }
}
@Test fun generationUpdatesPreservePlaybackPosition() = isolated { db ->
val book = db.saveBook(Book(title = "Audio source"))
val snapshot = com.aletheia.app.model.AudioBook(sourceBookId = book, title = "Audio", author = "Author")
val id = db.saveAudioBook(snapshot)
db.updateAudioPosition(id, 123456L)
db.saveAudioBook(snapshot.copy(id = id, currentChapter = "New metadata"))
assertEquals(123456L, db.getAudioBookById(id)?.positionMs)
}
@Test fun metadataAndLocationUpdatesPreserveNewestReadingPosition() = isolated { db ->
val old = Book(title = "Книга", author = "Автор")
val id = db.saveBook(old)
db.updateReadingState(id, 0.75, "epubcfi(/6/4)", "Глава", 75, 100)
db.updateMetadata(old.copy(id = id, coverImage = byteArrayOf(1, 2, 3)))
db.updateLocations(id, "locations")
val saved = requireNotNull(db.getBookById(id))
assertEquals(0.75, saved.readingProgress, 0.0)
assertEquals("epubcfi(/6/4)", saved.lastCfi)
assertEquals(75, saved.currentPage)
assertArrayEquals(byteArrayOf(1, 2, 3), saved.coverImage)
assertEquals("locations", saved.locations)
}
@Test fun remoteIdsAreUniqueOnlyWithinTheirSource() = isolated { db ->
val a = Book(title = "A", remoteId = "42", catalogSource = "https://one.test/opds")
val b = a.copy(title = "B", catalogSource = "https://two.test/opds")
val idA = db.saveBook(a)
val idB = db.saveBook(b)
assertEquals(idA, db.getBookByRemoteId(a.catalogSource!!, "42")?.id)
assertEquals(idB, db.getBookByRemoteId(b.catalogSource!!, "42")?.id)
assertNull(db.getBookByRemoteId("https://three.test", "42"))
try { db.saveBook(a); fail("Duplicate source/ID must be rejected") }
catch (_: android.database.sqlite.SQLiteConstraintException) { }
db.saveBook(a.copy(catalogSource = null))
db.saveBook(a.copy(catalogSource = null)) // unclassified legacy rows remain intact
}
private fun isolated(block: (AppDatabaseHelper) -> Unit) {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val name = "audit-${UUID.randomUUID()}.db"
val db = AppDatabaseHelper(context, name)
try { block(db) } finally { db.close(); context.deleteDatabase(name) }
}
}
@@ -0,0 +1,66 @@
package com.aletheia.app.data
import androidx.test.platform.app.InstrumentationRegistry
import com.aletheia.app.model.QBooksBook
import java.io.File
import java.net.ServerSocket
import java.util.UUID
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import kotlin.concurrent.thread
import kotlinx.coroutines.*
import org.junit.Assert.*
import org.junit.Test
class DownloadRegressionTest {
@Test fun twoScreensShareOneDownloadAndObserverCancellationDoesNotStopImport() = runBlocking {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val name = "download-${UUID.randomUUID()}.db"
val db = AppDatabaseHelper(context, name)
val folder = File(context.cacheDir, UUID.randomUUID().toString()).apply { mkdirs() }
val server = ServerSocket(0)
val received = CountDownLatch(1)
val respond = CountDownLatch(1)
val requests = AtomicInteger()
val body = """<?xml version="1.0" encoding="UTF-8"?><FictionBook xmlns="http://www.gribuser.ru/xml/fictionbook/2.0"><description><title-info><book-title>Тест загрузки</book-title><author><first-name>Иван</first-name></author></title-info></description><body><section><p>Текст</p></section></body></FictionBook>""".toByteArray()
val serving = thread {
server.accept().use { socket ->
val input = socket.getInputStream().bufferedReader()
while (!input.readLine().isNullOrEmpty()) { }
requests.incrementAndGet()
received.countDown()
check(respond.await(10, TimeUnit.SECONDS))
socket.getOutputStream().apply {
write("HTTP/1.1 200 OK\r\nContent-Length: ${body.size}\r\nConnection: close\r\n\r\n".toByteArray())
write(body); flush()
}
}
}
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
try {
val settings = SettingsRepository(context, db)
val source = "http://127.0.0.1:${server.localPort}"
settings.setString(SettingsRepository.KEY_QBOOKS_URL, source)
val parser = BookParserService(File(folder, "books"), folder) { android.util.Base64.decode(it, 0) }
val repo = BookRepository(db, parser, settings)
val downloads = BookDownloads(scope, QBooksService(settings, CatalogCache(context)), repo, folder)
val book = QBooksBook("42", "Тест", "Иван", "fb2", "$source/book.fb2", catalogSource = source)
val first = downloads.start(book)
assertTrue(received.await(10, TimeUnit.SECONDS))
val second = downloads.start(book)
assertSame(first, second)
val observer = launch { first.await() }
observer.cancelAndJoin()
respond.countDown()
val imported = withTimeout(10000) { second.await() }
assertEquals(1, requests.get())
assertEquals(1, db.getAllBooks().size)
assertEquals(imported.id, downloads.states.value[downloads.key(book)]?.bookId)
assertEquals("Тест загрузки", imported.title)
} finally {
respond.countDown(); server.close(); serving.join(1000); scope.cancel()
db.close(); context.deleteDatabase(name); folder.deleteRecursively()
}
}
}
@@ -0,0 +1,43 @@
package com.aletheia.app.data
import android.view.View
import androidx.test.core.app.ActivityScenario
import androidx.test.platform.app.InstrumentationRegistry
import com.aletheia.app.AletheiaApplication
import com.aletheia.app.R
import com.aletheia.app.model.Book
import com.aletheia.app.ui.main.MainActivity
import org.junit.Assert.*
import org.junit.Test
class LibraryUiRegressionTest {
@Test fun emptyFinishedFilterKeepsControlsAndResetRestoresBooks() {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val app = instrumentation.targetContext.applicationContext as AletheiaApplication
val id = app.databaseHelper.saveBook(Book(title = "Непрочитанная тестовая книга"))
try {
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
scenario.onActivity { it.selectTab(R.id.nav_my_books) }
awaitUi(scenario) { it.findViewById<View>(R.id.library_filter_finished)?.isShown == true }
scenario.onActivity { it.findViewById<View>(R.id.library_filter_finished).performClick() }
awaitUi(scenario) { it.findViewById<View>(R.id.empty_search_card)?.isShown == true }
scenario.onActivity {
assertTrue(it.findViewById<View>(R.id.library_filter_all).isShown)
it.findViewById<View>(R.id.clear_library_search_button).performClick()
}
awaitUi(scenario) { it.findViewById<View>(R.id.books_recycler)?.isShown == true }
scenario.onActivity { assertFalse(it.findViewById<View>(R.id.empty_search_card).isShown) }
}
} finally { app.databaseHelper.getBookById(id)?.let(app.databaseHelper::deleteBook) }
}
private fun awaitUi(scenario: ActivityScenario<MainActivity>, predicate: (MainActivity) -> Boolean) {
repeat(100) {
var ready = false
scenario.onActivity { ready = predicate(it) }
if (ready) return
Thread.sleep(100)
}
fail("UI state did not arrive within 10 seconds")
}
}
+11
View File
@@ -5,6 +5,8 @@
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<application <application
@@ -28,6 +30,15 @@
android:resource="@xml/file_paths" /> android:resource="@xml/file_paths" />
</provider> </provider>
<service
android:name=".audiobook.AudioPlaybackService"
android:exported="true"
android:foregroundServiceType="mediaPlayback">
<intent-filter>
<action android:name="androidx.media3.session.MediaSessionService" />
</intent-filter>
</service>
<service <service
android:name=".audiobook.AudioBookGenerationService" android:name=".audiobook.AudioBookGenerationService"
android:exported="false" android:exported="false"
@@ -13,6 +13,7 @@ import com.aletheia.app.ui.reader.ReaderStateCoordinator
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import xyz.kusoft.argusupdater.ArgusUpdateManager import xyz.kusoft.argusupdater.ArgusUpdateManager
@@ -47,6 +48,12 @@ class AletheiaApplication : Application() {
lateinit var appUpdateManager: ArgusUpdateManager lateinit var appUpdateManager: ArgusUpdateManager
private set private set
lateinit var bookDownloads: com.aletheia.app.data.BookDownloads
private set
lateinit var catalogFavorites: com.aletheia.app.data.CatalogFavorites
private set
lateinit var audioJobCleanup: com.aletheia.app.audiobook.AudioJobCleanup
private set
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
@@ -57,9 +64,20 @@ class AletheiaApplication : Application() {
settingsRepository = SettingsRepository(this, databaseHelper) settingsRepository = SettingsRepository(this, databaseHelper)
bookParserService = BookParserService(this) bookParserService = BookParserService(this)
bookRepository = BookRepository(databaseHelper, bookParserService, settingsRepository) bookRepository = BookRepository(databaseHelper, bookParserService, settingsRepository)
audioBookRepository = AudioBookRepository(this, databaseHelper) audioJobCleanup = com.aletheia.app.audiobook.AudioJobCleanup(this, applicationScope) {
com.aletheia.app.audiobook.RemoteAudioBookClient(this)
}
audioBookRepository = AudioBookRepository(this, databaseHelper, audioJobCleanup)
applicationScope.launch {
audioBookRepository.getAll().filter { it.status == com.aletheia.app.model.AudioBook.Status.READY }
.mapNotNull { it.remoteJobId }.forEach(audioJobCleanup::enqueue)
audioJobCleanup.flush()
}
audioJobCleanup.flush()
catalogCache = CatalogCache(this) catalogCache = CatalogCache(this)
qBooksService = QBooksService(settingsRepository, catalogCache) qBooksService = QBooksService(settingsRepository, catalogCache)
bookDownloads = com.aletheia.app.data.BookDownloads(applicationScope, qBooksService, bookRepository, cacheDir)
catalogFavorites = com.aletheia.app.data.CatalogFavorites(this, catalogCache)
appUpdateManager = ArgusUpdateManager(this) appUpdateManager = ArgusUpdateManager(this)
} }
} }
@@ -33,7 +33,7 @@ class AudioBookGenerationService : Service() {
private val jobs = ConcurrentHashMap<Long, Job>() private val jobs = ConcurrentHashMap<Long, Job>()
private val userCancellations = ConcurrentHashMap.newKeySet<Long>() private val userCancellations = ConcurrentHashMap.newKeySet<Long>()
private val app by lazy { application as AletheiaApplication } private val app by lazy { application as AletheiaApplication }
private val client by lazy { RemoteAudioBookClient() } private val client by lazy { RemoteAudioBookClient(this) }
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
@@ -76,6 +76,7 @@ class AudioBookGenerationService : Service() {
try { try {
var remoteJobId = record.remoteJobId var remoteJobId = record.remoteJobId
if (remoteJobId.isNullOrBlank()) { if (remoteJobId.isNullOrBlank()) {
client.checkAvailable()
record = saveProgress( record = saveProgress(
record.copy( record.copy(
status = AudioBook.Status.GENERATING, status = AudioBook.Status.GENERATING,
@@ -87,19 +88,25 @@ class AudioBookGenerationService : Service() {
) )
val created = client.create(source, book.title, book.author) val created = client.create(source, book.title, book.author)
remoteJobId = created.id remoteJobId = created.id
record = saveProgress(record.copy(remoteJobId = remoteJobId)) record = record.copy(remoteJobId = remoteJobId)
record = saveProgress(record)
} }
var consecutiveFailures = 0
while (true) { while (true) {
val remote = try { val remote = try {
client.status(remoteJobId) client.status(remoteJobId)
} catch (error: IOException) { } catch (error: IOException) {
consecutiveFailures = (consecutiveFailures + 1).coerceAtMost(4)
if (!AudioRetryPolicy.shouldRetry(error, consecutiveFailures)) throw error
record = saveProgress( record = saveProgress(
record.copy(currentChapter = "Нет связи с сервером, повтор через 15 секунд…") record.copy(status = AudioBook.Status.GENERATING, errorMessage = null,
currentChapter = "Нет связи с сервером. Ожидаем подключения…")
) )
delay(RETRY_DELAY_MS) delay((RETRY_DELAY_MS * consecutiveFailures).coerceAtMost(60000L))
continue continue
} }
consecutiveFailures = 0
record = saveProgress( record = saveProgress(
record.copy( record.copy(
@@ -108,7 +115,8 @@ class AudioBookGenerationService : Service() {
totalCharacters = remote.totalCharacters, totalCharacters = remote.totalCharacters,
durationMs = remote.durationMs, durationMs = remote.durationMs,
chaptersJson = remote.chaptersJson, chaptersJson = remote.chaptersJson,
currentChapter = remote.currentChapter ?: stageLabel(remote.stage), currentChapter = if (remote.stage == "synthesizing")
remote.currentChapter ?: stageLabel(remote.stage) else stageLabel(remote.stage),
errorMessage = null errorMessage = null
) )
) )
@@ -127,24 +135,39 @@ class AudioBookGenerationService : Service() {
partial.delete() partial.delete()
record = saveProgress(record.copy(currentChapter = "Скачивание готового M4A…")) record = saveProgress(record.copy(currentChapter = "Скачивание готового M4A…"))
client.download(remoteJobId, partial) while (true) {
try {
client.download(remoteJobId, partial)
break
} catch (error: IOException) {
if (!AudioRetryPolicy.shouldRetry(error, 1)) throw error
partial.delete()
record = saveProgress(record.copy(currentChapter = "Скачивание прервано. Ожидаем подключения…"))
delay(RETRY_DELAY_MS)
record = saveProgress(record.copy(currentChapter = "Скачивание готового M4A…"))
}
}
output.delete() output.delete()
check(partial.renameTo(output)) { "Не удалось сохранить готовый M4A-файл" } check(partial.renameTo(output)) { "Не удалось сохранить готовый M4A-файл" }
record = saveProgress( record = saveProgress(
record.copy( record.copy(
filePath = output.absolutePath, filePath = output.absolutePath,
remoteJobId = null,
status = AudioBook.Status.READY, status = AudioBook.Status.READY,
processedCharacters = record.totalCharacters, processedCharacters = record.totalCharacters,
currentChapter = null currentChapter = null
) )
) )
runCatching { client.cancelOrDelete(remoteJobId) } app.audioJobCleanup.enqueue(remoteJobId)
.onFailure { Log.w(TAG, "Remote job cleanup failed for id=$remoteJobId", it) } app.audioJobCleanup.flush()
notifyComplete(record) notifyComplete(record)
} catch (cancelled: CancellationException) { } catch (cancelled: CancellationException) {
if (app.audioBookRepository.getById(audioBookId) == null) {
partial.delete()
output.delete()
}
if (userCancellations.remove(audioBookId)) { if (userCancellations.remove(audioBookId)) {
record.remoteJobId?.let { runCatching { client.cancelOrDelete(it) } } record.remoteJobId?.let(app.audioJobCleanup::enqueue)
app.audioJobCleanup.flush()
partial.delete() partial.delete()
if (app.audioBookRepository.getById(audioBookId) != null) { if (app.audioBookRepository.getById(audioBookId) != null) {
saveProgress(record.copy(status = AudioBook.Status.CANCELLED, currentChapter = null)) saveProgress(record.copy(status = AudioBook.Status.CANCELLED, currentChapter = null))
@@ -168,7 +191,8 @@ class AudioBookGenerationService : Service() {
} }
scope.launch { scope.launch {
val record = app.audioBookRepository.getById(audioBookId) ?: return@launch val record = app.audioBookRepository.getById(audioBookId) ?: return@launch
record.remoteJobId?.let { runCatching { client.cancelOrDelete(it) } } record.remoteJobId?.let(app.audioJobCleanup::enqueue)
app.audioJobCleanup.flush()
app.audioBookRepository.partialOutputFile(audioBookId).delete() app.audioBookRepository.partialOutputFile(audioBookId).delete()
if (app.audioBookRepository.getById(audioBookId) != null) { if (app.audioBookRepository.getById(audioBookId) != null) {
saveProgress(record.copy(status = AudioBook.Status.CANCELLED, currentChapter = null)) saveProgress(record.copy(status = AudioBook.Status.CANCELLED, currentChapter = null))
@@ -177,13 +201,19 @@ class AudioBookGenerationService : Service() {
} }
} }
private fun saveProgress(audioBook: AudioBook): AudioBook = private fun saveProgress(audioBook: AudioBook): AudioBook {
app.audioBookRepository.save(audioBook).also(::broadcastProgress) if (app.audioBookRepository.getById(audioBook.id) == null) {
audioBook.remoteJobId?.let(app.audioJobCleanup::enqueue)
app.audioJobCleanup.flush()
throw CancellationException("Задание удалено")
}
return app.audioBookRepository.save(audioBook).also(::broadcastProgress)
}
private fun stageLabel(stage: String): String = when (stage) { private fun stageLabel(stage: String): String = when (stage) {
"queued" -> "В очереди на CMP-сервере…" "queued" -> "В очереди на CMP-сервере…"
"extracting" -> "Подготовка текста книги…" "extracting" -> "Подготовка текста книги…"
"loading_model" -> "Загрузка мужского голоса Qwen…" "loading_model" -> "Подготовка голоса VoiceStudio…"
"synthesizing" -> "Создание аудиокниги…" "synthesizing" -> "Создание аудиокниги…"
"encoding" -> "Сборка итогового M4A…" "encoding" -> "Сборка итогового M4A…"
"ready" -> "Аудиокнига готова" "ready" -> "Аудиокнига готова"
@@ -196,6 +226,7 @@ class AudioBookGenerationService : Service() {
} }
private fun fail(record: AudioBook, message: String) { private fun fail(record: AudioBook, message: String) {
if (app.audioBookRepository.getById(record.id) == null) return
val failed = saveProgress( val failed = saveProgress(
record.copy(status = AudioBook.Status.FAILED, currentChapter = null, errorMessage = message) record.copy(status = AudioBook.Status.FAILED, currentChapter = null, errorMessage = message)
) )
@@ -232,7 +263,7 @@ class AudioBookGenerationService : Service() {
private fun notification(id: Long, progress: Int, detail: String, ongoing: Boolean) = private fun notification(id: Long, progress: Int, detail: String, ongoing: Boolean) =
NotificationCompat.Builder(this, CHANNEL_ID) NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_reader_v2_voice) .setSmallIcon(R.drawable.ic_reader_v2_voice)
.setContentTitle("Создание аудиокниги · Qwen, мужской голос") .setContentTitle("Создание аудиокниги · VoiceStudio")
.setContentText(detail) .setContentText(detail)
.setOnlyAlertOnce(true) .setOnlyAlertOnce(true)
.setOngoing(ongoing) .setOngoing(ongoing)
@@ -0,0 +1,47 @@
package com.aletheia.app.audiobook
import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.AtomicFile
import java.io.File
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
class AudioDeviceCredentials(context: Context) {
private val file = AtomicFile(File(context.noBackupFilesDir, "audiobook-device.bin"))
private fun key(): SecretKey {
val store = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
(store.getKey(ALIAS, null) as? SecretKey)?.let { return it }
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").apply {
init(KeyGenParameterSpec.Builder(ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM).setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE).build())
}.generateKey()
}
fun token(): String = synchronized(LOCK) {
if (!file.baseFile.isFile) return@synchronized ""
runCatching {
val bytes = file.readFully()
require(bytes.size > 28)
Cipher.getInstance("AES/GCM/NoPadding").run {
init(Cipher.DECRYPT_MODE, key(), GCMParameterSpec(128, bytes.copyOfRange(0, 12)))
String(doFinal(bytes.copyOfRange(12, bytes.size)), Charsets.UTF_8)
}
}.getOrDefault("")
}
fun save(token: String) = synchronized(LOCK) {
require(token.length in 32..256)
val cipher = Cipher.getInstance("AES/GCM/NoPadding").apply { init(Cipher.ENCRYPT_MODE, key()) }
val bytes = cipher.iv + cipher.doFinal(token.toByteArray(Charsets.UTF_8))
val output = file.startWrite()
try { output.write(bytes); file.finishWrite(output) }
catch (error: Exception) { file.failWrite(output); throw error }
}
companion object {
private const val ALIAS = "aletheia.audio.device.v1"
private val LOCK = Any()
}
}
@@ -0,0 +1,13 @@
package com.aletheia.app.audiobook
class AudioHttpException(val status: Int) : java.io.IOException(when (status) {
401, 403 -> "Нет доступа к аудиосервису. Подключите устройство в настройках."
404 -> "Задание больше не существует на сервере. Создайте аудиокнигу повторно."
429 -> "Сервер занят. Повторите позже."
else -> "Сервер аудиокниг вернул HTTP $status"
})
object AudioRetryPolicy {
fun shouldRetry(error: java.io.IOException, failures: Int): Boolean =
(error !is AudioHttpException || error.status in listOf(408, 429) || error.status in 500..599)
}
@@ -0,0 +1,42 @@
package com.aletheia.app.audiobook
import android.content.Context
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/** Durable outbox: deleting a local row never loses the remote cancellation request. */
class AudioJobCleanup(context: Context, private val scope: CoroutineScope, private val client: () -> RemoteAudioBookClient) {
private val prefs = context.getSharedPreferences("audio_cleanup", Context.MODE_PRIVATE)
private var worker: Job? = null
@Synchronized fun enqueue(id: String) {
val ids = prefs.getStringSet("pending", emptySet()).orEmpty().toMutableSet()
ids.add(id)
check(prefs.edit().putStringSet("pending", ids).commit()) { "Не удалось сохранить отмену задания" }
}
@Synchronized private fun pending() = prefs.getStringSet("pending", emptySet()).orEmpty().toSet()
@Synchronized private fun remove(id: String) {
prefs.edit().putStringSet("pending", pending() - id).commit()
}
@Synchronized fun flush() {
if (worker?.isActive == true) return
worker = scope.launch {
var failures = 0
while (pending().isNotEmpty()) {
var failed = false
for (id in pending()) {
try { if (client().cancelOrDelete(id)) remove(id) else failed = true }
catch (error: Exception) {
if (error is kotlinx.coroutines.CancellationException) throw error
failed = true
}
}
if (failed) {
failures = (failures + 1).coerceAtMost(4)
delay((15000L * failures).coerceAtMost(60000))
}
}
}
}
}
@@ -0,0 +1,105 @@
package com.aletheia.app.audiobook
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
import androidx.media3.session.SessionCommand
import androidx.media3.session.SessionResult
import androidx.media3.session.SessionError
import com.aletheia.app.AletheiaApplication
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import kotlinx.coroutines.launch
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
class AudioPlaybackService : MediaSessionService() {
private lateinit var player: ExoPlayer
private var session: MediaSession? = null
private val handler = Handler(Looper.getMainLooper())
private var sleepDeadline = 0L
private var lastSave = 0L
private val app get() = application as AletheiaApplication
private val ticker = object : Runnable {
override fun run() {
if (sleepDeadline > 0 && SystemClock.elapsedRealtime() >= sleepDeadline) {
player.pause()
setSleepTimer(0)
}
if (SystemClock.elapsedRealtime() - lastSave >= 5000) persistPosition()
handler.postDelayed(this, 500)
}
}
override fun onCreate() {
super.onCreate()
player = ExoPlayer.Builder(this).build().apply {
setAudioAttributes(AudioAttributes.Builder().setUsage(C.USAGE_MEDIA)
.setContentType(C.AUDIO_CONTENT_TYPE_SPEECH).build(), true)
setHandleAudioBecomingNoisy(true)
setWakeMode(C.WAKE_MODE_LOCAL)
addListener(object : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) { persistPosition() }
override fun onPositionDiscontinuity(oldPosition: Player.PositionInfo, newPosition: Player.PositionInfo, reason: Int) {
if (oldPosition.mediaItem?.mediaId != newPosition.mediaItem?.mediaId) {
oldPosition.mediaItem?.mediaId?.toLongOrNull()?.let { id ->
app.applicationScope.launch { app.audioBookRepository.savePosition(id, oldPosition.positionMs) }
}
}
}
})
}
session = MediaSession.Builder(this, player).setCallback(object : MediaSession.Callback {
override fun onConnect(session: MediaSession, controller: MediaSession.ControllerInfo): MediaSession.ConnectionResult {
val defaults = super.onConnect(session, controller)
return MediaSession.ConnectionResult.AcceptedResultBuilder(session)
.setAvailableSessionCommands(defaults.availableSessionCommands.buildUpon()
.add(SessionCommand(SLEEP_COMMAND, Bundle.EMPTY)).build()).build()
}
override fun onCustomCommand(session: MediaSession, controller: MediaSession.ControllerInfo,
customCommand: SessionCommand, args: Bundle): ListenableFuture<SessionResult> {
if (controller.packageName != packageName || customCommand.customAction != SLEEP_COMMAND)
return Futures.immediateFuture(SessionResult(SessionError.ERROR_NOT_SUPPORTED))
setSleepTimer(args.getInt("minutes").coerceIn(0, 120))
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
}
}).build()
handler.post(ticker)
}
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? =
if (controllerInfo.packageName == packageName || controllerInfo.isTrusted) session else null
private fun setSleepTimer(minutes: Int) {
sleepDeadline = if (minutes == 0) 0 else SystemClock.elapsedRealtime() + minutes * 60000L
session?.setSessionExtras(Bundle().apply { putLong(SLEEP_DEADLINE, sleepDeadline) })
}
private fun persistPosition() {
if (!::player.isInitialized) return
val id = player.currentMediaItem?.mediaId?.toLongOrNull() ?: return
val position = player.currentPosition.coerceAtLeast(0)
lastSave = SystemClock.elapsedRealtime()
app.applicationScope.launch { app.audioBookRepository.savePosition(id, position) }
}
override fun onDestroy() {
handler.removeCallbacksAndMessages(null)
persistPosition()
session?.release()
player.release()
session = null
super.onDestroy()
}
companion object {
const val SLEEP_COMMAND = "aletheia.sleep"
const val SLEEP_DEADLINE = "sleep.deadline.elapsed"
}
}
@@ -0,0 +1,8 @@
package com.aletheia.app.audiobook
object PlaybackMath {
fun position(duration: Long, slider: Int): Long = duration.coerceAtLeast(0) / 1000 * slider.coerceIn(0, 1000) +
duration.coerceAtLeast(0) % 1000 * slider.coerceIn(0, 1000) / 1000
fun slider(position: Long, duration: Long): Int =
if (duration <= 0) 0 else (position.coerceIn(0, duration).toDouble() / duration * 1000).toInt().coerceIn(0, 1000)
}
@@ -12,9 +12,36 @@ import java.util.UUID
import org.json.JSONObject import org.json.JSONObject
class RemoteAudioBookClient( class RemoteAudioBookClient(
private val baseUrl: String = BuildConfig.AUDIOBOOK_API_BASE_URL, context: android.content.Context,
private val token: String = BuildConfig.AUDIOBOOK_API_TOKEN private val baseUrl: String = BuildConfig.AUDIOBOOK_API_BASE_URL
) { ) {
private val credentials = AudioDeviceCredentials(context)
fun checkAvailable() {
check(credentials.token().isNotBlank()) { "Подключите аудиосервис в настройках приложения" }
val connection = open("health").apply {
connectTimeout = 8_000
readTimeout = 8_000
}
try {
check(JSONObject(connection.readSuccessfulBody()).optString("status") == "ok") {
"CMP пока не готов к изготовлению аудиокниг"
}
} finally { connection.disconnect() }
}
fun pair(code: String, label: String) {
val connection = URL(baseUrl.trimEnd('/') + "/v1/devices/enroll").openConnection() as HttpURLConnection
try {
connection.connectTimeout = CONNECT_TIMEOUT_MS
connection.readTimeout = READ_TIMEOUT_MS
connection.requestMethod = "POST"
connection.doOutput = true
connection.setRequestProperty("Content-Type", "application/json")
connection.outputStream.use { it.write(JSONObject().put("code", code.trim()).put("label", label).toString().toByteArray(Charsets.UTF_8)) }
if (connection.responseCode !in 200..299) throw IOException("Код подключения недействителен, истёк или сервер недоступен")
val result = JSONObject(connection.inputStream.bufferedReader().use { it.readText() })
credentials.save(result.getString("token"))
} finally { connection.disconnect() }
}
fun create(source: File, title: String, author: String): RemoteJob { fun create(source: File, title: String, author: String): RemoteJob {
require(source.isFile) { "Файл исходной книги не найден" } require(source.isFile) { "Файл исходной книги не найден" }
val boundary = "aletheia-${UUID.randomUUID()}" val boundary = "aletheia-${UUID.randomUUID()}"
@@ -56,9 +83,10 @@ class RemoteAudioBookClient(
} }
try { try {
if (connection.responseCode !in 200..299) { if (connection.responseCode !in 200..299) {
throw IOException(connection.errorDescription()) throw AudioHttpException(connection.responseCode)
} }
destination.parentFile?.mkdirs() destination.parentFile?.mkdirs()
val expectedLength = connection.contentLengthLong
connection.inputStream.buffered(256 * 1024).use { input -> connection.inputStream.buffered(256 * 1024).use { input ->
FileOutputStream(destination).buffered(256 * 1024).use { output -> FileOutputStream(destination).buffered(256 * 1024).use { output ->
input.copyTo(output, 256 * 1024) input.copyTo(output, 256 * 1024)
@@ -67,26 +95,36 @@ class RemoteAudioBookClient(
check(destination.isFile && destination.length() > 0L) { check(destination.isFile && destination.length() > 0L) {
"Сервер вернул пустой M4A-файл" "Сервер вернул пустой M4A-файл"
} }
if (expectedLength >= 0 && destination.length() != expectedLength) {
throw IOException("Аудиофайл получен не полностью. Скачивание будет повторено")
}
java.io.RandomAccessFile(destination, "rw").use { it.fd.sync() }
} finally { } finally {
connection.disconnect() connection.disconnect()
} }
} }
fun cancelOrDelete(jobId: String) { fun cancelOrDelete(jobId: String): Boolean {
val connection = open("v1/audiobooks/$jobId").apply { requestMethod = "DELETE" } val connection = open("v1/audiobooks/$jobId").apply { requestMethod = "DELETE" }
try { try {
val code = connection.responseCode val code = connection.responseCode
if (code !in 200..299 && code != HttpURLConnection.HTTP_NOT_FOUND) { if (code !in 200..299 && code != HttpURLConnection.HTTP_NOT_FOUND) {
throw IOException(connection.errorDescription()) throw AudioHttpException(code)
} }
(if (code in 200..299) connection.inputStream else connection.errorStream)?.close() if (code == HttpURLConnection.HTTP_NOT_FOUND) {
connection.errorStream?.close()
return true
}
val body = connection.inputStream.bufferedReader(StandardCharsets.UTF_8).use { it.readText() }
return JSONObject(body).optString("status") == "deleted"
} finally { } finally {
connection.disconnect() connection.disconnect()
} }
} }
private fun open(path: String): HttpURLConnection { private fun open(path: String): HttpURLConnection {
check(token.isNotBlank()) { "Ключ сервера аудиокниг не добавлен в сборку" } val token = credentials.token()
check(token.isNotBlank()) { "Подключите аудиосервис в настройках приложения" }
return (URL(baseUrl.trimEnd('/') + "/" + path).openConnection() as HttpURLConnection).apply { return (URL(baseUrl.trimEnd('/') + "/" + path).openConnection() as HttpURLConnection).apply {
connectTimeout = CONNECT_TIMEOUT_MS connectTimeout = CONNECT_TIMEOUT_MS
readTimeout = READ_TIMEOUT_MS readTimeout = READ_TIMEOUT_MS
@@ -98,7 +136,7 @@ class RemoteAudioBookClient(
private fun HttpURLConnection.readSuccessfulBody(): String { private fun HttpURLConnection.readSuccessfulBody(): String {
val code = responseCode val code = responseCode
if (code !in 200..299) throw IOException(errorDescription()) if (code !in 200..299) throw AudioHttpException(code)
return inputStream.bufferedReader(StandardCharsets.UTF_8).use { it.readText() } return inputStream.bufferedReader(StandardCharsets.UTF_8).use { it.readText() }
} }
@@ -11,7 +11,7 @@ import com.aletheia.app.model.ReadingBookmark
import com.aletheia.app.model.ReadingNote import com.aletheia.app.model.ReadingNote
import com.aletheia.app.model.ReadingProgress import com.aletheia.app.model.ReadingProgress
class AppDatabaseHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) { class AppDatabaseHelper(context: Context, databaseName: String = DATABASE_NAME) : SQLiteOpenHelper(context, databaseName, null, DATABASE_VERSION) {
override fun onCreate(db: SQLiteDatabase) { override fun onCreate(db: SQLiteDatabase) {
db.execSQL( db.execSQL(
@@ -32,7 +32,8 @@ class AppDatabaseHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_N
last_read INTEGER NOT NULL, last_read INTEGER NOT NULL,
total_pages INTEGER NOT NULL DEFAULT 0, total_pages INTEGER NOT NULL DEFAULT 0,
current_page INTEGER NOT NULL DEFAULT 0, current_page INTEGER NOT NULL DEFAULT 0,
remote_id TEXT remote_id TEXT,
catalog_source TEXT
) )
""".trimIndent() """.trimIndent()
) )
@@ -63,6 +64,7 @@ class AppDatabaseHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_N
createBookmarksTable(db) createBookmarksTable(db)
createNotesTable(db) createNotesTable(db)
createAudioBooksTable(db) createAudioBooksTable(db)
createCatalogIndex(db)
} }
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
@@ -94,6 +96,12 @@ class AppDatabaseHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_N
if (oldVersion < 8 && !hasColumn(db, "audiobooks", "remote_job_id")) { if (oldVersion < 8 && !hasColumn(db, "audiobooks", "remote_job_id")) {
db.execSQL("ALTER TABLE audiobooks ADD COLUMN remote_job_id TEXT") db.execSQL("ALTER TABLE audiobooks ADD COLUMN remote_job_id TEXT")
} }
if (oldVersion < 9) {
db.execSQL("ALTER TABLE books ADD COLUMN catalog_source TEXT")
// Historical rows have no trustworthy origin. Preserve them unlinked rather than
// guessing a server and associating a different book with the same remote ID.
createCatalogIndex(db)
}
} }
fun getAllBooks(): List<Book> = readableDatabase.query( fun getAllBooks(): List<Book> = readableDatabase.query(
@@ -162,6 +170,48 @@ class AppDatabaseHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_N
if (cursor.moveToFirst()) cursor.getString(0) else null if (cursor.moveToFirst()) cursor.getString(0) else null
} }
fun getBookByRemoteId(source: String, remoteId: String): Book? = readableDatabase.query(
"books", null, "catalog_source = ? AND remote_id = ?", arrayOf(source, remoteId),
null, null, null, "1"
).use { if (it.moveToFirst()) cursorToBook(it) else null }
fun updateMetadata(book: Book) {
writableDatabase.update("books", ContentValues().apply {
put("title", book.title)
put("author", book.author)
put("cover_image", book.coverImage)
}, "id = ?", arrayOf(book.id.toString()))
}
fun updateLocations(bookId: Long, locations: String) {
writableDatabase.update("books", ContentValues().apply { put("locations", locations) },
"id = ?", arrayOf(bookId.toString()))
}
fun updateAudioPosition(id: Long, position: Long) {
writableDatabase.update("audiobooks", ContentValues().apply {
put("position_ms", position.coerceAtLeast(0))
}, "id = ?", arrayOf(id.toString()))
}
fun updateReadingState(bookId: Long, progress: Double, cfi: String?, chapter: String?, page: Int, total: Int) {
val db = writableDatabase
db.beginTransaction()
try {
val changed = db.update("books", ContentValues().apply {
put("reading_progress", progress)
put("last_cfi", cfi)
put("last_chapter", chapter)
put("current_page", page)
put("total_pages", total)
put("last_read", System.currentTimeMillis())
}, "id = ?", arrayOf(bookId.toString()))
if (changed > 0) saveProgress(ReadingProgress(bookId = bookId, cfi = cfi,
progress = progress, currentPage = page, chapterTitle = chapter))
db.setTransactionSuccessful()
} finally { db.endTransaction() }
}
fun getAllAudioBooks(): List<AudioBook> = readableDatabase.query( fun getAllAudioBooks(): List<AudioBook> = readableDatabase.query(
"audiobooks", null, null, null, null, null, "updated_at DESC" "audiobooks", null, null, null, null, null, "updated_at DESC"
).use { cursor -> ).use { cursor ->
@@ -181,6 +231,7 @@ class AppDatabaseHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_N
return if (audioBook.id == 0L) { return if (audioBook.id == 0L) {
writableDatabase.insertWithOnConflict("audiobooks", null, values, SQLiteDatabase.CONFLICT_REPLACE) writableDatabase.insertWithOnConflict("audiobooks", null, values, SQLiteDatabase.CONFLICT_REPLACE)
} else { } else {
values.remove("position_ms") // Playback owns this field independently of generation/metadata.
writableDatabase.update("audiobooks", values, "id = ?", arrayOf(audioBook.id.toString())) writableDatabase.update("audiobooks", values, "id = ?", arrayOf(audioBook.id.toString()))
audioBook.id audioBook.id
} }
@@ -403,6 +454,7 @@ class AppDatabaseHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_N
put("total_pages", book.totalPages) put("total_pages", book.totalPages)
put("current_page", book.currentPage) put("current_page", book.currentPage)
put("remote_id", book.remoteId) put("remote_id", book.remoteId)
put("catalog_source", book.catalogSource)
} }
private fun audioBookToValues(audioBook: AudioBook) = ContentValues().apply { private fun audioBookToValues(audioBook: AudioBook) = ContentValues().apply {
@@ -439,7 +491,8 @@ class AppDatabaseHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_N
lastRead = cursor.getLong(cursor.getColumnIndexOrThrow("last_read")), lastRead = cursor.getLong(cursor.getColumnIndexOrThrow("last_read")),
totalPages = cursor.getInt(cursor.getColumnIndexOrThrow("total_pages")), totalPages = cursor.getInt(cursor.getColumnIndexOrThrow("total_pages")),
currentPage = cursor.getInt(cursor.getColumnIndexOrThrow("current_page")), currentPage = cursor.getInt(cursor.getColumnIndexOrThrow("current_page")),
remoteId = cursor.getString(cursor.getColumnIndexOrThrow("remote_id")) remoteId = cursor.getString(cursor.getColumnIndexOrThrow("remote_id")),
catalogSource = cursor.getString(cursor.getColumnIndexOrThrow("catalog_source"))
) )
private fun cursorToAudioBook(cursor: Cursor) = AudioBook( private fun cursorToAudioBook(cursor: Cursor) = AudioBook(
@@ -590,9 +643,13 @@ class AppDatabaseHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_N
return false return false
} }
private fun createCatalogIndex(db: SQLiteDatabase) {
db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS idx_books_catalog ON books(catalog_source, remote_id) WHERE catalog_source IS NOT NULL AND remote_id IS NOT NULL")
}
companion object { companion object {
private const val DATABASE_NAME = "aletheia.db3" private const val DATABASE_NAME = "aletheia.db3"
private const val DATABASE_VERSION = 8 private const val DATABASE_VERSION = 9
private const val MAX_READING_HISTORY_ENTRIES = 200 private const val MAX_READING_HISTORY_ENTRIES = 200
} }
} }
@@ -7,19 +7,23 @@ import java.io.File
class AudioBookRepository( class AudioBookRepository(
context: Context, context: Context,
private val databaseHelper: AppDatabaseHelper private val databaseHelper: AppDatabaseHelper,
private val cleanup: com.aletheia.app.audiobook.AudioJobCleanup
) { ) {
private val outputDirectory = File(context.cacheDir, "audiobooks").apply { mkdirs() } private val outputDirectory = File(context.filesDir, "Audiobooks").apply { mkdirs() }
private val oldDirectory = File(context.cacheDir, "audiobooks")
fun getAll(): List<AudioBook> = databaseHelper.getAllAudioBooks().map(::reconcileFile) @Synchronized fun getAll(): List<AudioBook> = databaseHelper.getAllAudioBooks().map(::reconcileFile)
fun getById(id: Long): AudioBook? = databaseHelper.getAudioBookById(id)?.let(::reconcileFile) @Synchronized fun getById(id: Long): AudioBook? = databaseHelper.getAudioBookById(id)?.let(::reconcileFile)
fun getBySourceBookId(bookId: Long): AudioBook? = @Synchronized fun getBySourceBookId(bookId: Long): AudioBook? =
databaseHelper.getAudioBookBySourceBookId(bookId)?.let(::reconcileFile) databaseHelper.getAudioBookBySourceBookId(bookId)?.let(::reconcileFile)
fun createOrReset(book: Book): AudioBook { @Synchronized fun createOrReset(book: Book): AudioBook {
val existing = databaseHelper.getAudioBookBySourceBookId(book.id) val existing = databaseHelper.getAudioBookBySourceBookId(book.id)
existing?.remoteJobId?.let(cleanup::enqueue)
cleanup.flush()
existing?.filePath?.takeIf(String::isNotBlank)?.let { File(it).delete() } existing?.filePath?.takeIf(String::isNotBlank)?.let { File(it).delete() }
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val audioBook = AudioBook( val audioBook = AudioBook(
@@ -33,32 +37,43 @@ class AudioBookRepository(
updatedAt = now updatedAt = now
) )
val id = databaseHelper.saveAudioBook(audioBook) val id = databaseHelper.saveAudioBook(audioBook)
databaseHelper.updateAudioPosition(id, 0)
return requireNotNull(databaseHelper.getAudioBookById(id)) return requireNotNull(databaseHelper.getAudioBookById(id))
} }
fun save(audioBook: AudioBook): AudioBook { @Synchronized fun save(audioBook: AudioBook): AudioBook {
if (audioBook.id != 0L && databaseHelper.getAudioBookById(audioBook.id) == null) {
audioBook.remoteJobId?.let(cleanup::enqueue)
cleanup.flush()
throw kotlinx.coroutines.CancellationException("Задание удалено")
}
val id = databaseHelper.saveAudioBook(audioBook.copy(updatedAt = System.currentTimeMillis())) val id = databaseHelper.saveAudioBook(audioBook.copy(updatedAt = System.currentTimeMillis()))
return requireNotNull(databaseHelper.getAudioBookById(id)) return requireNotNull(databaseHelper.getAudioBookById(id))
} }
fun savePosition(id: Long, positionMs: Long) { fun savePosition(id: Long, positionMs: Long) {
val current = databaseHelper.getAudioBookById(id) ?: return databaseHelper.updateAudioPosition(id, positionMs)
databaseHelper.saveAudioBook(
current.copy(positionMs = positionMs.coerceAtLeast(0), updatedAt = System.currentTimeMillis())
)
} }
fun outputFile(audioBookId: Long): File = File(outputDirectory, "audiobook-$audioBookId.m4a") fun outputFile(audioBookId: Long): File = File(outputDirectory, "audiobook-$audioBookId.m4a")
fun partialOutputFile(audioBookId: Long): File = File(outputDirectory, "audiobook-$audioBookId.partial.m4a") fun partialOutputFile(audioBookId: Long): File = File(outputDirectory, "audiobook-$audioBookId.partial.m4a")
fun delete(audioBook: AudioBook) { @Synchronized fun delete(audioBook: AudioBook) {
databaseHelper.getAudioBookById(audioBook.id)?.remoteJobId?.let(cleanup::enqueue)
audioBook.filePath.takeIf(String::isNotBlank)?.let { File(it).delete() } audioBook.filePath.takeIf(String::isNotBlank)?.let { File(it).delete() }
partialOutputFile(audioBook.id).delete() partialOutputFile(audioBook.id).delete()
databaseHelper.deleteAudioBook(audioBook.id) databaseHelper.deleteAudioBook(audioBook.id)
cleanup.flush()
} }
private fun reconcileFile(audioBook: AudioBook): AudioBook { private fun reconcileFile(audioBook: AudioBook): AudioBook {
val oldFile = File(audioBook.filePath)
if (audioBook.status == AudioBook.Status.READY && oldFile.isFile && oldFile.parentFile == oldDirectory) {
val destination = outputFile(audioBook.id)
// Atomic rename within the same private volume; never delete the old copy on failure.
if (oldFile.renameTo(destination)) return save(audioBook.copy(filePath = destination.absolutePath))
}
if (audioBook.status != AudioBook.Status.READY || File(audioBook.filePath).isFile) { if (audioBook.status != AudioBook.Status.READY || File(audioBook.filePath).isFile) {
return audioBook return audioBook
} }
@@ -0,0 +1,74 @@
package com.aletheia.app.data
import com.aletheia.app.model.Book
import com.aletheia.app.model.QBooksBook
import java.io.File
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
data class DownloadState(val progress: Int = 0, val running: Boolean = false,
val bookId: Long? = null, val error: String? = null)
/** Application-owned work: navigating away never cancels a download. */
class BookDownloads(private val scope: CoroutineScope, private val service: QBooksService,
private val repository: BookRepository, private val cache: File) {
private val mutableStates = MutableStateFlow<Map<String, DownloadState>>(emptyMap())
val states = mutableStates.asStateFlow()
private val jobs = mutableMapOf<String, Deferred<Book>>()
fun key(book: QBooksBook): String = "${source(book)}\n${book.id}"
private fun source(book: QBooksBook) = book.catalogSource.ifBlank { repository.catalogSource() }
private fun publish(key: String, value: DownloadState) { mutableStates.update { it + (key to value) } }
@Synchronized
fun start(book: QBooksBook): Deferred<Book> {
val key = key(book)
jobs[key]?.takeIf { !it.isCompleted }?.let { return it }
val source = source(book)
val job = scope.async(start = CoroutineStart.LAZY) {
publish(key, DownloadState(running = true))
try {
val local = repository.getBookByRemoteId(book.id, source) ?: run {
check(source == repository.catalogSource()) { "Каталог изменился. Откройте книгу в исходном каталоге." }
val temp = when (val result = service.downloadBook(book, cache) {
publish(key, DownloadState(progress = it, running = true))
}) {
is Result.Success -> result.value
is Result.Failure -> throw java.io.IOException(result.message)
}
try {
currentCoroutineContext().ensureActive()
val name = book.title.replace(Regex("[\\\\/:*?\"<>|]"), "_") + "." + book.format.lowercase()
repository.importDownloadedBook(temp, name, book.id, book.coverImage, source)
} finally { temp.delete() }
}
publish(key, DownloadState(progress = 100, bookId = local.id))
local
} catch (cancelled: CancellationException) {
publish(key, DownloadState(error = "Загрузка отменена"))
throw cancelled
} catch (error: Exception) {
publish(key, DownloadState(error = error.message ?: "Не удалось скачать книгу"))
throw error
}
}
jobs[key] = job
job.invokeOnCompletion { synchronized(this) { if (jobs[key] === job) jobs.remove(key) } }
job.start()
return job
}
fun refresh(books: List<QBooksBook>) = scope.launch {
books.forEach { book ->
val key = key(book)
val local = repository.getBookByRemoteId(book.id, source(book))
mutableStates.update { states ->
if (states[key]?.running == true) states else states + (key to
(local?.let { DownloadState(progress = 100, bookId = it.id) }
?: states[key]?.takeIf { it.bookId == null } ?: DownloadState()))
}
}
}
}
@@ -112,8 +112,7 @@ class BookParserService private constructor(
private fun parseFb2(book: Book): Book { private fun parseFb2(book: Book): Book {
return try { return try {
val xml = File(book.filePath).readText() val document = File(book.filePath).inputStream().use(::parseXml)
val document = parseXml(ByteArrayInputStream(xml.toByteArray()))
val title = document.getElementsByTagNameNS("*", "book-title").item(0)?.textContent?.trim().orEmpty() val title = document.getElementsByTagNameNS("*", "book-title").item(0)?.textContent?.trim().orEmpty()
val authorNode = document.getElementsByTagNameNS("*", "author").item(0) as? Element val authorNode = document.getElementsByTagNameNS("*", "author").item(0) as? Element
val firstName = authorNode?.getElementsByTagNameNS("*", "first-name")?.item(0)?.textContent?.trim().orEmpty() val firstName = authorNode?.getElementsByTagNameNS("*", "first-name")?.item(0)?.textContent?.trim().orEmpty()
@@ -27,8 +27,10 @@ class BookRepository(
fun getBookById(id: Long): Book? = databaseHelper.getBookById(id) fun getBookById(id: Long): Book? = databaseHelper.getBookById(id)
fun getBookByRemoteId(remoteId: String): Book? = fun catalogSource(): String = settingsRepository.getCatalogUrl().trimEnd('/')
databaseHelper.getAllBooks().firstOrNull { it.remoteId == remoteId }
fun getBookByRemoteId(remoteId: String, source: String = catalogSource()): Book? =
databaseHelper.getBookByRemoteId(source, remoteId)
fun importBook(uri: Uri): Book { fun importBook(uri: Uri): Book {
val book = parserService.parseAndStoreBookFromUri(uri) val book = parserService.parseAndStoreBookFromUri(uri)
@@ -36,9 +38,11 @@ class BookRepository(
return requireNotNull(databaseHelper.getBookById(id)) return requireNotNull(databaseHelper.getBookById(id))
} }
fun importDownloadedBook(file: File, originalFileName: String, remoteId: String?, coverImage: ByteArray?): Book { @Synchronized
fun importDownloadedBook(file: File, originalFileName: String, remoteId: String?, coverImage: ByteArray?, source: String = catalogSource()): Book {
remoteId?.let { getBookByRemoteId(it, source)?.let { existing -> return existing } }
val parsedBook = parserService.parseAndStoreBook(file, originalFileName) val parsedBook = parserService.parseAndStoreBook(file, originalFileName)
val book = parsedBook.copy(remoteId = remoteId, coverImage = coverImage ?: parsedBook.coverImage) val book = parsedBook.copy(remoteId = remoteId, catalogSource = source, coverImage = coverImage ?: parsedBook.coverImage)
val id = databaseHelper.saveBook(book) val id = databaseHelper.saveBook(book)
return requireNotNull(databaseHelper.getBookById(id)) return requireNotNull(databaseHelper.getBookById(id))
} }
@@ -57,12 +61,11 @@ class BookRepository(
} }
fun updateBook(book: Book) { fun updateBook(book: Book) {
databaseHelper.saveBook(book) databaseHelper.updateMetadata(book)
} }
fun saveLocations(bookId: Long, locations: String) { fun saveLocations(bookId: Long, locations: String) {
val book = getBookById(bookId) ?: return databaseHelper.updateLocations(bookId, locations)
updateBook(book.copy(locations = locations))
} }
fun saveProgress( fun saveProgress(
@@ -73,25 +76,7 @@ class BookRepository(
currentPage: Int, currentPage: Int,
totalPages: Int totalPages: Int
) { ) {
val book = getBookById(bookId) ?: return databaseHelper.updateReadingState(bookId, progress, cfi, chapter, currentPage, totalPages)
val updated = book.copy(
readingProgress = progress,
lastCfi = cfi,
lastChapter = chapter,
currentPage = currentPage,
totalPages = totalPages,
lastRead = System.currentTimeMillis()
)
updateBook(updated)
databaseHelper.saveProgress(
ReadingProgress(
bookId = bookId,
cfi = cfi,
progress = progress,
currentPage = currentPage,
chapterTitle = chapter
)
)
} }
fun getBookmarks(bookId: Long): List<ReadingBookmark> = fun getBookmarks(bookId: Long): List<ReadingBookmark> =
@@ -17,6 +17,9 @@ class CatalogCache(context: Context) {
private val coversDirectory = File(context.cacheDir, "catalog-v1/covers").apply { mkdirs() } private val coversDirectory = File(context.cacheDir, "catalog-v1/covers").apply { mkdirs() }
private val lock = Any() private val lock = Any()
fun encode(book: QBooksBook): String = book.toJson().toString()
fun decode(json: String): QBooksBook? = runCatching { JSONObject(json).toBook() }.getOrNull()
data class CachedPage( data class CachedPage(
val page: CatalogPage, val page: CatalogPage,
val savedAtMillis: Long val savedAtMillis: Long
@@ -120,6 +123,7 @@ class CatalogCache(context: Context) {
private fun QBooksBook.toJson(): JSONObject = JSONObject().apply { private fun QBooksBook.toJson(): JSONObject = JSONObject().apply {
put("id", id) put("id", id)
put("catalogSource", catalogSource)
put("title", title) put("title", title)
put("author", author) put("author", author)
put("format", format) put("format", format)
@@ -139,6 +143,7 @@ class CatalogCache(context: Context) {
if (id.isBlank() || title.isBlank()) return null if (id.isBlank() || title.isBlank()) return null
return QBooksBook( return QBooksBook(
id = id, id = id,
catalogSource = optString("catalogSource"),
title = title, title = title,
author = optString("author").trim().ifBlank { "Неизвестный автор" }, author = optString("author").trim().ifBlank { "Неизвестный автор" },
format = optString("format").trim().ifBlank { "epub" }, format = optString("format").trim().ifBlank { "epub" },
@@ -0,0 +1,52 @@
package com.aletheia.app.data
import android.content.Context
import com.aletheia.app.model.QBooksBook
import org.json.JSONObject
/** Metadata lives in preferences, independently of the evictable catalog cache. */
class CatalogFavorites(context: Context, private val codec: CatalogCache) {
private val prefs = context.getSharedPreferences("catalog_favorites", Context.MODE_PRIVATE)
private fun key(book: QBooksBook) = book.catalogSource + "\n" + book.id
private fun records() = runCatching { JSONObject(prefs.getString("records-v2", "{}")!!) }.getOrDefault(JSONObject())
@Synchronized fun contains(book: QBooksBook): Boolean = records().has(key(book)) ||
(book.catalogSource == "legacy" && book.id in prefs.getStringSet("book_ids", emptySet()).orEmpty())
@Synchronized fun toggle(book: QBooksBook) {
if (book.catalogSource == "legacy") {
prefs.edit().putStringSet("book_ids", prefs.getStringSet("book_ids", emptySet()).orEmpty() - book.id).apply()
return
}
val records = records()
if (records.has(key(book))) records.remove(key(book)) else records.put(key(book), codec.encode(book))
prefs.edit().putString("records-v2", records.toString()).apply()
}
@Synchronized fun all(): List<QBooksBook> {
val records = records()
val known = records.keys().asSequence().mapNotNull { codec.decode(records.getString(it)) }.toList()
val legacy = prefs.getStringSet("book_ids", emptySet()).orEmpty().map { id ->
QBooksBook(id, "Сохранённая книга: $id", "Источник старой записи неизвестен", "epub", "",
description = "Предыдущая версия сохранила только ID. Найдите книгу в исходном каталоге и добавьте в избранное заново.", catalogSource = "legacy")
}
return known + legacy
}
// Only globally-addressed legacy IDs can be matched safely. Numeric IDs from an
// unknown server remain visible as unclassified entries, never assigned by guessing.
@Synchronized fun recoverLegacy(books: List<QBooksBook>) {
val legacy = prefs.getStringSet("book_ids", emptySet()).orEmpty().toMutableSet()
if (legacy.isEmpty()) return
val records = records()
books.filter { book -> book.id in legacy && runCatching {
val id = java.net.URI(book.id)
id.scheme in listOf("http", "https") && id.host != null &&
id.host.equals(java.net.URI(book.downloadUrl).host, ignoreCase = true)
}.getOrDefault(false) }.forEach {
records.put(key(it), codec.encode(it))
legacy.remove(it.id)
}
prefs.edit().putString("records-v2", records.toString()).putStringSet("book_ids", legacy).apply()
}
}
@@ -5,6 +5,7 @@ import com.aletheia.app.model.Book
import com.aletheia.app.model.QBooksBook import com.aletheia.app.model.QBooksBook
import com.aletheia.app.model.CatalogPage import com.aletheia.app.model.CatalogPage
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.json.JSONObject import org.json.JSONObject
import java.io.File import java.io.File
@@ -56,6 +57,12 @@ class QBooksService(
this.password = normalizedPassword this.password = normalizedPassword
} }
suspend fun navigation(url: String? = null): List<OpdsNavigationEntry> = withContext(Dispatchers.IO) {
configureFromSettings()
if (detectBackend() != Backend.OPDS) return@withContext emptyList()
fetchOpdsNavigation(url ?: requireBaseUrl())
}
suspend fun testConnection(url: String, username: String?, password: String?): Result<Unit> = withContext(Dispatchers.IO) { suspend fun testConnection(url: String, username: String?, password: String?): Result<Unit> = withContext(Dispatchers.IO) {
runCatching { runCatching {
configure(url, username, password) configure(url, username, password)
@@ -88,7 +95,8 @@ class QBooksService(
): CatalogCache.CachedPage? = withContext(Dispatchers.IO) { ): CatalogCache.CachedPage? = withContext(Dispatchers.IO) {
configureFromSettings() configureFromSettings()
catalogCache.readPage(catalogCacheKey(searchQuery, page, pageUrl))?.let { cached -> catalogCache.readPage(catalogCacheKey(searchQuery, page, pageUrl))?.let { cached ->
cached.copy(page = cached.page.copy(books = cached.page.books.take(pageSize.coerceAtLeast(1)))) cached.copy(page = cached.page.copy(books = cached.page.books.take(pageSize.coerceAtLeast(1))
.map { it.copy(catalogSource = settingsRepository.getCatalogUrl().trimEnd('/')) }))
} }
} }
@@ -96,6 +104,8 @@ class QBooksService(
book: QBooksBook, book: QBooksBook,
includeDetails: Boolean = false includeDetails: Boolean = false
): QBooksBook = withContext(Dispatchers.IO) { ): QBooksBook = withContext(Dispatchers.IO) {
if (book.catalogSource.isNotBlank() && book.catalogSource != settingsRepository.getCatalogUrl().trimEnd('/'))
return@withContext book
configureFromSettings() configureFromSettings()
val cacheKey = bookCacheKey(book.id) val cacheKey = bookCacheKey(book.id)
var enriched = catalogCache.readBook(cacheKey)?.let { cached -> book.mergeMissing(cached) } ?: book var enriched = catalogCache.readBook(cacheKey)?.let { cached -> book.mergeMissing(cached) } ?: book
@@ -126,6 +136,7 @@ class QBooksService(
} }
suspend fun enrichLocalBook(book: Book): Book = withContext(Dispatchers.IO) { suspend fun enrichLocalBook(book: Book): Book = withContext(Dispatchers.IO) {
if (book.catalogSource != null && book.catalogSource != settingsRepository.getCatalogUrl().trimEnd('/')) return@withContext book
if (!book.needsCatalogMetadata()) return@withContext book if (!book.needsCatalogMetadata()) return@withContext book
configureFromSettings() configureFromSettings()
val synthetic = QBooksBook( val synthetic = QBooksBook(
@@ -157,6 +168,7 @@ class QBooksService(
runCatching { runCatching {
configureFromSettings() configureFromSettings()
val query = searchQuery.trim() val query = searchQuery.trim()
val source = settingsRepository.getCatalogUrl().trimEnd('/')
val catalogPage = when (detectBackend()) { val catalogPage = when (detectBackend()) {
Backend.QBOOKS -> { Backend.QBOOKS -> {
val books = getBooksFromQBooks(query, page, maxOf(pageSize, DEFAULT_PAGE_SIZE)) val books = getBooksFromQBooks(query, page, maxOf(pageSize, DEFAULT_PAGE_SIZE))
@@ -169,7 +181,7 @@ class QBooksService(
Backend.UNKNOWN -> error( Backend.UNKNOWN -> error(
"Сервер не поддерживает книжный каталог. Проверьте адрес каталога." "Сервер не поддерживает книжный каталог. Проверьте адрес каталога."
) )
} }.let { it.copy(books = it.books.map { book -> book.copy(catalogSource = source) }) }
runCatching { runCatching {
catalogCache.writePage(catalogCacheKey(query, page, pageUrl), catalogPage) catalogCache.writePage(catalogCacheKey(query, page, pageUrl), catalogPage)
catalogPage.books.forEach { book -> catalogPage.books.forEach { book ->
@@ -190,6 +202,9 @@ class QBooksService(
var destination: File? = null var destination: File? = null
runCatching { runCatching {
configureFromSettings() configureFromSettings()
check(book.catalogSource.isBlank() || book.catalogSource == requireBaseUrl()) {
"Каталог изменился. Откройте книгу в исходном каталоге."
}
if (!booksDirectory.exists()) { if (!booksDirectory.exists()) {
booksDirectory.mkdirs() booksDirectory.mkdirs()
} }
@@ -209,6 +224,7 @@ class QBooksService(
FileOutputStream(target).use { output -> FileOutputStream(target).use { output ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE) val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
while (true) { while (true) {
kotlinx.coroutines.currentCoroutineContext().ensureActive()
val read = input.read(buffer) val read = input.read(buffer)
if (read == -1) break if (read == -1) break
output.write(buffer, 0, read) output.write(buffer, 0, read)
@@ -225,6 +241,7 @@ class QBooksService(
Result.Success(target) Result.Success(target)
}.getOrElse { throwable -> }.getOrElse { throwable ->
destination?.takeIf(File::exists)?.delete() destination?.takeIf(File::exists)?.delete()
if (throwable is kotlinx.coroutines.CancellationException) throw throwable
Result.Failure(qBooksConnectionError("Не удалось скачать книгу.", throwable), throwable) Result.Failure(qBooksConnectionError("Не удалось скачать книгу.", throwable), throwable)
} }
} }
@@ -19,8 +19,8 @@ data class AudioBook(
val updatedAt: Long = System.currentTimeMillis() val updatedAt: Long = System.currentTimeMillis()
) { ) {
val progressPercent: Int val progressPercent: Int
get() = if (totalCharacters <= 0) 0 else get() = if (status == Status.READY) 100 else if (totalCharacters <= 0) 0 else
((processedCharacters * 100L) / totalCharacters).toInt().coerceIn(0, 100) ((processedCharacters.toDouble() / totalCharacters) * 100).toInt().coerceIn(0, 99)
enum class Status(val databaseValue: String) { enum class Status(val databaseValue: String) {
QUEUED("queued"), GENERATING("generating"), READY("ready"), QUEUED("queued"), GENERATING("generating"), READY("ready"),
@@ -16,7 +16,8 @@ data class Book(
val lastRead: Long = System.currentTimeMillis(), val lastRead: Long = System.currentTimeMillis(),
val totalPages: Int = 0, val totalPages: Int = 0,
val currentPage: Int = 0, val currentPage: Int = 0,
val remoteId: String? = null val remoteId: String? = null,
val catalogSource: String? = null
) { ) {
val progressText: String val progressText: String
get() = "${(readingProgress * 100).toInt()}%" get() = "${(readingProgress * 100).toInt()}%"
@@ -12,5 +12,6 @@ data class QBooksBook(
val description: String? = null, val description: String? = null,
val language: String? = null, val language: String? = null,
val publisher: String? = null, val publisher: String? = null,
val published: String? = null val published: String? = null,
val catalogSource: String = ""
) : java.io.Serializable ) : java.io.Serializable
@@ -3,7 +3,18 @@ package com.aletheia.app.ui.audiobook
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.media.AudioAttributes import android.media.AudioAttributes
import android.media.MediaPlayer import android.content.ComponentName
import android.os.SystemClock
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.Player
import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken
import androidx.media3.session.SessionCommand
import com.aletheia.app.audiobook.AudioPlaybackService
import com.aletheia.app.audiobook.PlaybackMath
import androidx.core.content.ContextCompat
import com.google.common.util.concurrent.ListenableFuture
import android.os.Bundle import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
@@ -26,11 +37,13 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.json.JSONArray import org.json.JSONArray
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
class AudioBookPlayerActivity : AppCompatActivity() { class AudioBookPlayerActivity : AppCompatActivity() {
private lateinit var binding: ActivityAudioBookPlayerBinding private lateinit var binding: ActivityAudioBookPlayerBinding
private val app by lazy { application as AletheiaApplication } private val app by lazy { application as AletheiaApplication }
private val handler = Handler(Looper.getMainLooper()) private val handler = Handler(Looper.getMainLooper())
private var mediaPlayer: MediaPlayer? = null private var mediaPlayer: MediaController? = null
private var controllerFuture: ListenableFuture<MediaController>? = null
private var audioBook: AudioBook? = null private var audioBook: AudioBook? = null
private var chapters: List<Chapter> = emptyList() private var chapters: List<Chapter> = emptyList()
private var prepared = false private var prepared = false
@@ -67,12 +80,17 @@ class AudioBookPlayerActivity : AppCompatActivity() {
override fun onDestroy() { override fun onDestroy() {
handler.removeCallbacksAndMessages(null) handler.removeCallbacksAndMessages(null)
mediaPlayer?.release() controllerFuture?.let(MediaController::releaseFuture)
mediaPlayer = null mediaPlayer = null
super.onDestroy() super.onDestroy()
} }
private fun setupControls() { private fun setupControls() {
binding.playerMoreButton.contentDescription = "Действия с аудиокнигой"
binding.playerRewindButton.contentDescription = "Назад на 15 секунд"
binding.playerForwardButton.contentDescription = "Вперёд на 30 секунд"
binding.playerPlayButton.contentDescription = "Воспроизвести"
binding.playerSleepButton.contentDescription = "Установить таймер сна"
binding.playerBackButton.setOnClickListener { finish() } binding.playerBackButton.setOnClickListener { finish() }
binding.playerPlayButton.setOnClickListener { togglePlayback() } binding.playerPlayButton.setOnClickListener { togglePlayback() }
binding.playerRewindButton.setOnClickListener { seekBy(-15_000) } binding.playerRewindButton.setOnClickListener { seekBy(-15_000) }
@@ -100,7 +118,7 @@ class AudioBookPlayerActivity : AppCompatActivity() {
override fun onStartTrackingTouch(seekBar: SeekBar?) { userSeeking = true } override fun onStartTrackingTouch(seekBar: SeekBar?) { userSeeking = true }
override fun onStopTrackingTouch(seekBar: SeekBar?) { override fun onStopTrackingTouch(seekBar: SeekBar?) {
val duration = mediaPlayer?.duration?.takeIf { it > 0 } ?: 0 val duration = mediaPlayer?.duration?.takeIf { it > 0 } ?: 0
mediaPlayer?.seekTo(duration * (seekBar?.progress ?: 0) / 1000) mediaPlayer?.seekTo(PlaybackMath.position(duration.toLong(), seekBar?.progress ?: 0))
userSeeking = false userSeeking = false
updateProgress() updateProgress()
} }
@@ -131,37 +149,45 @@ class AudioBookPlayerActivity : AppCompatActivity() {
} }
private fun preparePlayer(audio: AudioBook) { private fun preparePlayer(audio: AudioBook) {
mediaPlayer = MediaPlayer().apply { val future = MediaController.Builder(this, SessionToken(this,
setAudioAttributes( ComponentName(this, AudioPlaybackService::class.java))).buildAsync()
AudioAttributes.Builder() controllerFuture = future
.setUsage(AudioAttributes.USAGE_MEDIA) future.addListener({
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) if (isFinishing || isDestroyed) return@addListener
.build() try {
) val controller = future.get()
setDataSource(audio.filePath) mediaPlayer = controller
setOnPreparedListener { player -> controller.addListener(object : Player.Listener {
prepared = true override fun onEvents(player: Player, events: Player.Events) {
player.seekTo(audio.positionMs.coerceAtMost(player.duration.toLong()).toInt()) prepared = player.duration > 0
applySpeed() updateProgress()
}
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
Toast.makeText(this@AudioBookPlayerActivity, "Не удалось воспроизвести аудиокнигу", Toast.LENGTH_LONG).show()
}
})
if (controller.currentMediaItem?.mediaId != audio.id.toString()) {
controller.setMediaItem(MediaItem.Builder().setMediaId(audio.id.toString())
.setUri(android.net.Uri.fromFile(File(audio.filePath)))
.setMediaMetadata(MediaMetadata.Builder().setTitle(audio.title).setArtist(audio.author).build()).build(),
audio.positionMs)
controller.prepare()
}
prepared = controller.duration > 0
updateProgress() updateProgress()
} catch (error: Exception) {
Toast.makeText(this, "Не удалось подключить плеер", Toast.LENGTH_LONG).show()
} }
setOnCompletionListener { }, ContextCompat.getMainExecutor(this))
binding.playerPlayButton.text = "▶"
updateProgress()
savePosition()
}
setOnErrorListener { _, _, _ ->
Toast.makeText(this@AudioBookPlayerActivity, "Не удалось воспроизвести M4A-файл", Toast.LENGTH_LONG).show()
true
}
prepareAsync()
}
} }
private fun togglePlayback() { private fun togglePlayback() {
val player = mediaPlayer ?: return val player = mediaPlayer ?: return
if (!prepared) return if (!prepared) return
if (player.isPlaying) player.pause() else player.start() if (player.isPlaying) player.pause() else {
if (player.playbackState == Player.STATE_ENDED) player.seekTo(0)
player.play()
}
binding.playerPlayButton.text = if (player.isPlaying) "Ⅱ" else "▶" binding.playerPlayButton.text = if (player.isPlaying) "Ⅱ" else "▶"
} }
@@ -181,7 +207,7 @@ class AudioBookPlayerActivity : AppCompatActivity() {
val speed = SPEEDS[speedIndex] val speed = SPEEDS[speedIndex]
binding.playerSpeedButton.text = "${speed.toDisplay()}x" binding.playerSpeedButton.text = "${speed.toDisplay()}x"
if (prepared) runCatching { if (prepared) runCatching {
mediaPlayer?.playbackParams = mediaPlayer!!.playbackParams.setSpeed(speed) mediaPlayer?.setPlaybackSpeed(speed)
} }
} }
@@ -191,8 +217,8 @@ class AudioBookPlayerActivity : AppCompatActivity() {
MaterialAlertDialogBuilder(this) MaterialAlertDialogBuilder(this)
.setTitle("Таймер сна") .setTitle("Таймер сна")
.setItems(labels) { _, index -> .setItems(labels) { _, index ->
sleepAtMs = minutes[index].takeIf { it > 0 }?.let { System.currentTimeMillis() + it * 60_000L } mediaPlayer?.sendCustomCommand(SessionCommand(AudioPlaybackService.SLEEP_COMMAND, Bundle.EMPTY),
binding.playerSleepButton.alpha = if (sleepAtMs == null) 1f else 0.45f Bundle().apply { putInt("minutes", minutes[index]) })
} }
.show() .show()
} }
@@ -200,17 +226,17 @@ class AudioBookPlayerActivity : AppCompatActivity() {
private fun updateProgress() { private fun updateProgress() {
val player = mediaPlayer ?: return val player = mediaPlayer ?: return
if (!prepared) return if (!prepared) return
sleepAtMs?.let { deadline -> val deadline = player.sessionExtras.getLong(AudioPlaybackService.SLEEP_DEADLINE)
if (System.currentTimeMillis() >= deadline) { val remaining = if (deadline == 0L) 0L else (deadline - SystemClock.elapsedRealtime()).coerceAtLeast(0)
if (player.isPlaying) player.pause() binding.playerSleepButton.text = if (remaining == 0L) "◴" else "${(remaining + 59999) / 60000}м"
binding.playerPlayButton.text = "▶" binding.playerSleepButton.contentDescription = if (remaining == 0L) "Установить таймер сна" else "Таймер сна: ${formatTime(remaining)}"
sleepAtMs = null binding.playerPlayButton.text = if (player.isPlaying) "Ⅱ" else "▶"
binding.playerSleepButton.alpha = 1f binding.playerPlayButton.contentDescription = if (player.isPlaying) "Пауза" else "Воспроизвести"
} binding.playerSpeedButton.text = "${player.playbackParameters.speed.toDisplay()}x"
} binding.playerSpeedButton.contentDescription = "Скорость воспроизведения ${player.playbackParameters.speed.toDisplay()}"
val position = player.currentPosition.coerceAtLeast(0) val position = player.currentPosition.coerceAtLeast(0)
val duration = player.duration.coerceAtLeast(1) val duration = player.duration.coerceAtLeast(1)
if (!userSeeking) binding.playerSeekBar.progress = position * 1000 / duration if (!userSeeking) binding.playerSeekBar.progress = PlaybackMath.slider(position, duration)
binding.playerElapsed.text = formatTime(position.toLong()) binding.playerElapsed.text = formatTime(position.toLong())
binding.playerRemaining.text = "-${formatTime((duration - position).toLong())}" binding.playerRemaining.text = "-${formatTime((duration - position).toLong())}"
binding.playerRemainingSummary.text = remainingSummary((duration - position).toLong()) binding.playerRemainingSummary.text = remainingSummary((duration - position).toLong())
@@ -218,9 +244,7 @@ class AudioBookPlayerActivity : AppCompatActivity() {
} }
private fun savePosition() { private fun savePosition() {
val id = audioBook?.id ?: return // The playback service owns persistence, including while this screen is stopped.
val position = mediaPlayer?.currentPosition?.toLong() ?: return
app.applicationScope.launch { app.audioBookRepository.savePosition(id, position) }
} }
private fun parseChapters(json: String): List<Chapter> = runCatching { private fun parseChapters(json: String): List<Chapter> = runCatching {
@@ -49,6 +49,7 @@ class BookshelfFragment : Fragment() {
private val binding get() = _binding!! private val binding get() = _binding!!
private val app by lazy { requireActivity().application as AletheiaApplication } private val app by lazy { requireActivity().application as AletheiaApplication }
private lateinit var audioBooksAdapter: BooksAdapter
private lateinit var booksAdapter: BooksAdapter private lateinit var booksAdapter: BooksAdapter
private lateinit var qBooksShelfAdapter: QBooksShelfAdapter private lateinit var qBooksShelfAdapter: QBooksShelfAdapter
private var currentBooks: List<Book> = emptyList() private var currentBooks: List<Book> = emptyList()
@@ -59,6 +60,9 @@ class BookshelfFragment : Fragment() {
private val metadataJobs = mutableListOf<Job>() private val metadataJobs = mutableListOf<Job>()
private var loadBooksJob: Job? = null private var loadBooksJob: Job? = null
private var libraryFilter = LibraryFilter.ALL private var libraryFilter = LibraryFilter.ALL
private var generationDialog: androidx.appcompat.app.AlertDialog? = null
private var generationDialogId: Long? = null
private val startingAudioBooks = mutableSetOf<Long>()
private val notificationPermissionLauncher = registerForActivityResult( private val notificationPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission() ActivityResultContracts.RequestPermission()
@@ -98,6 +102,7 @@ class BookshelfFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
pendingAudioExportId = savedInstanceState?.getLong("audio.export.id") ?: 0L
booksAdapter = BooksAdapter( booksAdapter = BooksAdapter(
onBookClicked = ::openBook, onBookClicked = ::openBook,
@@ -110,6 +115,16 @@ class BookshelfFragment : Fragment() {
binding.booksRecycler.layoutManager = LinearLayoutManager(requireContext(), RecyclerView.HORIZONTAL, false) binding.booksRecycler.layoutManager = LinearLayoutManager(requireContext(), RecyclerView.HORIZONTAL, false)
binding.booksRecycler.addItemDecoration(BookshelfRailDecoration(requireContext(), dp(12), dp(8), dp(8))) binding.booksRecycler.addItemDecoration(BookshelfRailDecoration(requireContext(), dp(12), dp(8), dp(8)))
binding.booksRecycler.adapter = booksAdapter binding.booksRecycler.adapter = booksAdapter
audioBooksAdapter = BooksAdapter(
onBookClicked = ::openBook,
onAudioBookClicked = ::openAudioBook,
onAddBookClicked = ::launchImport,
onAudioBookActionsClicked = ::showAudioBookActions,
includeAddItem = false
)
binding.audioBooksRecycler.layoutManager = LinearLayoutManager(requireContext(), RecyclerView.HORIZONTAL, false)
binding.audioBooksRecycler.addItemDecoration(BookshelfRailDecoration(requireContext(), dp(12), dp(8), dp(8)))
binding.audioBooksRecycler.adapter = audioBooksAdapter
qBooksShelfAdapter = QBooksShelfAdapter( qBooksShelfAdapter = QBooksShelfAdapter(
onBookClicked = ::openQBooksBookPreview, onBookClicked = ::openQBooksBookPreview,
@@ -145,7 +160,10 @@ class BookshelfFragment : Fragment() {
continueReadingBook?.let(::openBook) continueReadingBook?.let(::openBook)
} }
binding.clearLibrarySearchButton.setOnClickListener { binding.clearLibrarySearchButton.setOnClickListener {
libraryFilter = LibraryFilter.ALL
binding.libraryFilterGroup.check(R.id.library_filter_all)
binding.booksSearchInput.setText("") binding.booksSearchInput.setText("")
renderFilteredBooks()
} }
binding.libraryFilterGroup.setOnCheckedStateChangeListener { _, checkedIds -> binding.libraryFilterGroup.setOnCheckedStateChangeListener { _, checkedIds ->
libraryFilter = when (checkedIds.firstOrNull()) { libraryFilter = when (checkedIds.firstOrNull()) {
@@ -167,6 +185,7 @@ class BookshelfFragment : Fragment() {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
resumeAudioBooks()
loadBooks() loadBooks()
} }
@@ -187,11 +206,15 @@ class BookshelfFragment : Fragment() {
override fun onHiddenChanged(hidden: Boolean) { override fun onHiddenChanged(hidden: Boolean) {
super.onHiddenChanged(hidden) super.onHiddenChanged(hidden)
if (!hidden && _binding != null) { if (!hidden && _binding != null) {
resumeAudioBooks()
loadBooks() loadBooks()
} }
} }
override fun onDestroyView() { override fun onDestroyView() {
generationDialog?.dismiss()
generationDialog = null
generationDialogId = null
loadBooksJob?.cancel() loadBooksJob?.cancel()
loadBooksJob = null loadBooksJob = null
metadataJobs.forEach { it.cancel() } metadataJobs.forEach { it.cancel() }
@@ -200,6 +223,16 @@ class BookshelfFragment : Fragment() {
_binding = null _binding = null
} }
private fun resumeAudioBooks() {
viewLifecycleOwner.lifecycleScope.launch {
val records = withContext(Dispatchers.IO) { app.audioBookRepository.getAll() }
records.filter {
it.status == AudioBook.Status.QUEUED || it.status == AudioBook.Status.GENERATING ||
(it.status == AudioBook.Status.FAILED && !it.remoteJobId.isNullOrBlank())
}.forEach { AudioBookGenerationService.start(requireContext(), it.id) }
}
}
private fun loadBooks() { private fun loadBooks() {
loadBooksJob?.cancel() loadBooksJob?.cancel()
loadBooksJob = viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) { loadBooksJob = viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
@@ -214,6 +247,7 @@ class BookshelfFragment : Fragment() {
result.onSuccess { (books, audioBooks) -> result.onSuccess { (books, audioBooks) ->
currentBooks = books currentBooks = books
currentAudioBooks = audioBooks currentAudioBooks = audioBooks
audioBooks.firstOrNull { it.id == generationDialogId }?.let(::updateGenerationDialog)
renderFilteredBooks() renderFilteredBooks()
enrichMissingMetadata(books.take(METADATA_PREFETCH_LIMIT)) enrichMissingMetadata(books.take(METADATA_PREFETCH_LIMIT))
}.onFailure { exception -> }.onFailure { exception ->
@@ -244,8 +278,9 @@ class BookshelfFragment : Fragment() {
visibleBooks = filterBooks(currentBooks, query) visibleBooks = filterBooks(currentBooks, query)
visibleAudioBooks = filterAudioBooks(currentAudioBooks, query) visibleAudioBooks = filterAudioBooks(currentAudioBooks, query)
val covers = currentBooks.associate { it.id to it.coverImage } val covers = currentBooks.associate { it.id to it.coverImage }
booksAdapter.submitContent( booksAdapter.submitList(visibleBooks)
visibleBooks, audioBooksAdapter.submitContent(
emptyList(),
visibleAudioBooks.map { it to covers[it.sourceBookId] } visibleAudioBooks.map { it to covers[it.sourceBookId] }
) )
renderLibraryState( renderLibraryState(
@@ -274,11 +309,12 @@ class BookshelfFragment : Fragment() {
val isLibraryEmpty = allBooks.isEmpty() val isLibraryEmpty = allBooks.isEmpty()
val isSearchEmpty = !isLibraryEmpty && displayedBooks.isEmpty() && displayedAudioBooks.isEmpty() val isSearchEmpty = !isLibraryEmpty && displayedBooks.isEmpty() && displayedAudioBooks.isEmpty()
val hasVisibleBooks = displayedBooks.isNotEmpty() || displayedAudioBooks.isNotEmpty() val hasVisibleBooks = displayedBooks.isNotEmpty()
binding.emptyLibraryCard.visibility = if (isLibraryEmpty) View.VISIBLE else View.GONE binding.emptyLibraryCard.visibility = if (isLibraryEmpty) View.VISIBLE else View.GONE
binding.emptySearchCard.visibility = if (isSearchEmpty) View.VISIBLE else View.GONE binding.emptySearchCard.visibility = if (isSearchEmpty) View.VISIBLE else View.GONE
binding.bookshelfPanel.visibility = if (hasVisibleBooks) View.VISIBLE else View.GONE binding.bookshelfPanel.visibility = if (!isLibraryEmpty) View.VISIBLE else View.GONE
binding.booksRecycler.visibility = if (hasVisibleBooks) View.VISIBLE else View.GONE binding.booksRecycler.visibility = if (hasVisibleBooks) View.VISIBLE else View.GONE
binding.audioBookshelfPanel.visibility = if (displayedAudioBooks.isNotEmpty()) View.VISIBLE else View.GONE
binding.continueReadingCard.visibility = binding.continueReadingCard.visibility =
if (continueReadingBook != null) View.VISIBLE else View.GONE if (continueReadingBook != null) View.VISIBLE else View.GONE
@@ -448,6 +484,36 @@ class BookshelfFragment : Fragment() {
} }
} }
private var pendingAudioExportId = 0L
private val exportAudioLauncher = registerForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.CreateDocument("audio/mp4")
) { uri ->
if (uri != null) {
val id = pendingAudioExportId
val context = requireContext().applicationContext
viewLifecycleOwner.lifecycleScope.launch {
try {
withContext(Dispatchers.IO) {
val audio = requireNotNull(app.audioBookRepository.getById(id))
context.contentResolver.openOutputStream(uri).use { output ->
requireNotNull(output)
java.io.File(audio.filePath).inputStream().use { it.copyTo(output) }
}
}
android.widget.Toast.makeText(context, "Аудиокнига сохранена", android.widget.Toast.LENGTH_SHORT).show()
} catch (error: Exception) {
if (error is kotlinx.coroutines.CancellationException) throw error
showMessage("Не удалось сохранить аудиокнигу: ${error.message}")
}
}
}
}
override fun onSaveInstanceState(outState: Bundle) {
outState.putLong("audio.export.id", pendingAudioExportId)
super.onSaveInstanceState(outState)
}
private fun showAudioBookActions(anchor: View, audioBook: AudioBook) { private fun showAudioBookActions(anchor: View, audioBook: AudioBook) {
PopupMenu(requireContext(), anchor).apply { PopupMenu(requireContext(), anchor).apply {
when (audioBook.status) { when (audioBook.status) {
@@ -460,6 +526,13 @@ class BookshelfFragment : Fragment() {
true true
} }
} }
if (audioBook.status == AudioBook.Status.READY) {
menu.add("Сохранить M4A…").setOnMenuItemClickListener {
pendingAudioExportId = audioBook.id
exportAudioLauncher.launch(audioBook.title.replace(Regex("[\\\\/:*?\"<>|]"), "_") + ".m4a")
true
}
}
menu.add("Удалить аудиокнигу").setOnMenuItemClickListener { menu.add("Удалить аудиокнигу").setOnMenuItemClickListener {
confirmDeleteAudioBook(audioBook) confirmDeleteAudioBook(audioBook)
true true
@@ -476,8 +549,8 @@ class BookshelfFragment : Fragment() {
MaterialAlertDialogBuilder(requireContext()) MaterialAlertDialogBuilder(requireContext())
.setTitle("Создать аудиокнигу?") .setTitle("Создать аудиокнигу?")
.setMessage( .setMessage(
warning + "Книга будет отправлена на ваш CUDA-сервер и озвучена мужским голосом Qwen. " + warning + "Книга будет отправлена на CMP и озвучена мужским голосом VoiceStudio. " +
"Приложение покажет прогресс и скачает готовый M4A в свой кэш. Создание может занять продолжительное время." "Приложение покажет прогресс и сохранит готовый M4A в библиотеке. Создание может занять продолжительное время."
) )
.setPositiveButton("Начать") { _, _ -> startAudioBookGeneration(book) } .setPositiveButton("Начать") { _, _ -> startAudioBookGeneration(book) }
.setNegativeButton(R.string.action_cancel, null) .setNegativeButton(R.string.action_cancel, null)
@@ -485,32 +558,67 @@ class BookshelfFragment : Fragment() {
} }
private fun startAudioBookGeneration(book: Book) { private fun startAudioBookGeneration(book: Book) {
if (!startingAudioBooks.add(book.id)) return
val context = requireContext().applicationContext
if (Build.VERSION.SDK_INT >= 33 && if (Build.VERSION.SDK_INT >= 33 &&
ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.POST_NOTIFICATIONS) != ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.POST_NOTIFICATIONS) !=
android.content.pm.PackageManager.PERMISSION_GRANTED android.content.pm.PackageManager.PERMISSION_GRANTED
) { ) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
} }
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) { viewLifecycleOwner.lifecycleScope.launch {
val record = app.audioBookRepository.createOrReset(book) try {
withContext(Dispatchers.Main) { val record = withContext(Dispatchers.IO) {
if (_binding == null) return@withContext com.aletheia.app.audiobook.RemoteAudioBookClient(context).checkAvailable()
AudioBookGenerationService.start(requireContext(), record.id) app.audioBookRepository.createOrReset(book).also {
loadBooks() AudioBookGenerationService.start(context, it.id)
}
}
if (_binding != null) {
showGenerationProgress(record)
loadBooks()
}
} catch (cancelled: kotlinx.coroutines.CancellationException) {
throw cancelled
} catch (error: Exception) {
if (_binding != null) showMessage("Не удалось начать изготовление: ${error.readableMessage()}")
} finally {
startingAudioBooks.remove(book.id)
} }
} }
} }
private fun showGenerationProgress(audioBook: AudioBook) { private fun showGenerationProgress(audioBook: AudioBook) {
val chapter = audioBook.currentChapter?.let { "\nСейчас: $it" }.orEmpty() generationDialog?.dismiss()
MaterialAlertDialogBuilder(requireContext()) generationDialogId = audioBook.id
generationDialog = MaterialAlertDialogBuilder(requireContext())
.setTitle("Создание аудиокниги · ${audioBook.progressPercent}%") .setTitle("Создание аудиокниги · ${audioBook.progressPercent}%")
.setMessage("Обработано ${audioBook.processedCharacters} из ${audioBook.totalCharacters} символов.$chapter") .setMessage("Подключение к CMP…")
.setPositiveButton("Продолжить в фоне", null) .setPositiveButton("Продолжить в фоне", null)
.setNegativeButton("Отменить") { _, _ -> .setNegativeButton("Отменить") { _, _ ->
AudioBookGenerationService.cancel(requireContext(), audioBook.id) AudioBookGenerationService.cancel(requireContext(), audioBook.id)
} }
.show() .show()
generationDialog?.setOnDismissListener {
generationDialog = null
generationDialogId = null
}
updateGenerationDialog(audioBook)
}
private fun updateGenerationDialog(audioBook: AudioBook) {
val finished = audioBook.status in setOf(AudioBook.Status.READY, AudioBook.Status.FAILED, AudioBook.Status.CANCELLED)
generationDialog?.apply {
setTitle(if (audioBook.status == AudioBook.Status.READY) "Аудиокнига готова" else "Создание аудиокниги · ${audioBook.progressPercent}%")
setMessage(when (audioBook.status) {
AudioBook.Status.READY -> "Готовая аудиокнига сохранена в библиотеке."
AudioBook.Status.FAILED -> audioBook.errorMessage ?: "Ошибка изготовления"
AudioBook.Status.CANCELLED -> "Изготовление отменено"
else -> "Обработано ${audioBook.processedCharacters} из ${audioBook.totalCharacters} символов.\n${audioBook.currentChapter ?: "Подключение к CMP…"}"
})
getButton(android.content.DialogInterface.BUTTON_NEGATIVE)?.visibility = if (finished) View.GONE else View.VISIBLE
getButton(android.content.DialogInterface.BUTTON_POSITIVE)?.text = if (finished) "Закрыть" else "Продолжить в фоне"
}
} }
private fun confirmDeleteAudioBook(audioBook: AudioBook) { private fun confirmDeleteAudioBook(audioBook: AudioBook) {
@@ -64,9 +64,10 @@ class HomeFragment : Fragment() {
binding.homeSearch.setOnClickListener { openSearch() } binding.homeSearch.setOnClickListener { openSearch() }
binding.catalogAll.setOnClickListener { openSearch() } binding.catalogAll.setOnClickListener { openSearch() }
binding.downloadedAll.setOnClickListener { openMyBooks() } binding.downloadedAll.setOnClickListener { openMyBooks() }
listOf(binding.chipGenres, binding.chipRecommendations, binding.chipBooks, binding.chipAuthors).forEach { chip -> binding.chipGenres.setOnClickListener { openCatalogMode("genres") }
chip.setOnClickListener { openSearch() } binding.chipRecommendations.setOnClickListener { openCatalogMode("selection") }
} binding.chipBooks.setOnClickListener { openCatalogMode("new") }
binding.chipAuthors.setOnClickListener { openCatalogMode("authors") }
binding.homeBannerFirst.setOnClickListener { openBanner(0) } binding.homeBannerFirst.setOnClickListener { openBanner(0) }
binding.homeBannerSecond.setOnClickListener { openBanner(1) } binding.homeBannerSecond.setOnClickListener { openBanner(1) }
binding.homeBannerThird.setOnClickListener { openBanner(2) } binding.homeBannerThird.setOnClickListener { openBanner(2) }
@@ -215,6 +216,11 @@ class HomeFragment : Fragment() {
startActivity(BookDetailActivity.createIntent(requireContext(), book)) startActivity(BookDetailActivity.createIntent(requireContext(), book))
} }
private fun openCatalogMode(mode: String) {
parentFragmentManager.setFragmentResult("catalog-mode", Bundle().apply { putString("mode", mode) })
openSearch()
}
private fun openSearch() { private fun openSearch() {
(activity as? MainActivity)?.selectTab(R.id.nav_search) (activity as? MainActivity)?.selectTab(R.id.nav_search)
} }
@@ -49,6 +49,20 @@ class BookDetailActivity : AppCompatActivity() {
binding.backButton.setOnClickListener { finish() } binding.backButton.setOnClickListener { finish() }
binding.shareButton.setOnClickListener { shareBook() } binding.shareButton.setOnClickListener { shareBook() }
binding.favoriteButton.setOnClickListener { toggleFavorite() } binding.favoriteButton.setOnClickListener { toggleFavorite() }
book = book.copy(catalogSource = book.catalogSource.ifBlank { app.bookRepository.catalogSource() })
app.catalogFavorites.recoverLegacy(listOf(book))
lifecycleScope.launch {
app.bookDownloads.states.collect { states ->
val state = states[app.bookDownloads.key(book)]
isDownloading = state?.running == true
if (isDownloading) {
binding.primaryAction.isEnabled = false
binding.primaryAction.text = getString(R.string.book_detail_downloading, state?.progress ?: 0)
binding.downloadProgress.visibility = View.VISIBLE
binding.downloadProgress.progress = state?.progress ?: 0
} else loadState()
}
}
renderBook() renderBook()
loadState() loadState()
loadMetadataAndCover() loadMetadataAndCover()
@@ -118,7 +132,7 @@ class BookDetailActivity : AppCompatActivity() {
private fun loadState() { private fun loadState() {
lifecycleScope.launch { lifecycleScope.launch {
localBook = withContext(Dispatchers.IO) { app.bookRepository.getBookByRemoteId(book.id) } localBook = withContext(Dispatchers.IO) { app.bookRepository.getBookByRemoteId(book.id, book.catalogSource) }
updatePrimaryAction() updatePrimaryAction()
} }
} }
@@ -127,7 +141,7 @@ class BookDetailActivity : AppCompatActivity() {
if (isDownloading) return if (isDownloading) return
val downloaded = localBook val downloaded = localBook
binding.downloadProgress.visibility = View.GONE binding.downloadProgress.visibility = View.GONE
binding.primaryAction.isEnabled = true binding.primaryAction.isEnabled = book.catalogSource != "legacy"
binding.primaryAction.text = getString( binding.primaryAction.text = getString(
if (downloaded == null) R.string.book_detail_download else R.string.book_detail_read if (downloaded == null) R.string.book_detail_download else R.string.book_detail_read
) )
@@ -139,54 +153,17 @@ class BookDetailActivity : AppCompatActivity() {
} }
private fun downloadBook() { private fun downloadBook() {
if (isDownloading) return
isDownloading = true
binding.primaryAction.isEnabled = false
binding.downloadProgress.visibility = View.VISIBLE
binding.downloadProgress.progress = 0
binding.primaryAction.text = getString(R.string.book_detail_downloading, 0)
lifecycleScope.launch { lifecycleScope.launch {
val result = withContext(Dispatchers.IO) { try {
runCatching { localBook = app.bookDownloads.start(book).await()
app.bookRepository.getBookByRemoteId(book.id)?.let { return@runCatching it } isDownloading = false
val progress: (Int) -> Unit = { value ->
runOnUiThread {
binding.downloadProgress.progress = value
binding.primaryAction.text = getString(R.string.book_detail_downloading, value)
}
}
val temp = when (val download = app.qBooksService.downloadBook(
book,
app.cacheDir,
progress
)) {
is Result.Success -> download.value
is Result.Failure -> error(download.message)
}
try {
app.bookRepository.importDownloadedBook(
file = temp,
originalFileName = safeFileName(book),
remoteId = book.id,
coverImage = book.coverImage
)
} finally {
temp.takeIf(File::exists)?.delete()
}
}
}
isDownloading = false
result.onSuccess {
localBook = it
updatePrimaryAction() updatePrimaryAction()
}.onFailure { } catch (error: Exception) {
if (error is kotlinx.coroutines.CancellationException) throw error
isDownloading = false
updatePrimaryAction() updatePrimaryAction()
MaterialAlertDialogBuilder(this@BookDetailActivity) MaterialAlertDialogBuilder(this@BookDetailActivity).setTitle(R.string.dialog_error_title)
.setTitle(R.string.dialog_error_title) .setMessage(error.message).setPositiveButton(R.string.action_ok, null).show()
.setMessage(getString(R.string.dialog_download_book_failed, it.message ?: it.javaClass.simpleName))
.setPositiveButton(R.string.action_ok, null)
.show()
} }
} }
} }
@@ -198,7 +175,7 @@ class BookDetailActivity : AppCompatActivity() {
private fun shareBook() { private fun shareBook() {
lifecycleScope.launch { lifecycleScope.launch {
val downloadedBook = withContext(Dispatchers.IO) { val downloadedBook = withContext(Dispatchers.IO) {
app.bookRepository.getBookByRemoteId(book.id) app.bookRepository.getBookByRemoteId(book.id, book.catalogSource)
} }
runCatching { runCatching {
if (downloadedBook == null) { if (downloadedBook == null) {
@@ -217,22 +194,15 @@ class BookDetailActivity : AppCompatActivity() {
} }
private fun toggleFavorite() { private fun toggleFavorite() {
val prefs = getSharedPreferences(FAVORITES_PREFS, MODE_PRIVATE) app.catalogFavorites.toggle(book)
val favorites = prefs.getStringSet(FAVORITES_KEY, emptySet()).orEmpty().toMutableSet()
if (!favorites.add(book.id)) favorites.remove(book.id)
prefs.edit().putStringSet(FAVORITES_KEY, favorites).apply()
renderFavorite() renderFavorite()
} }
private fun renderFavorite() { private fun renderFavorite() {
val selected = getSharedPreferences(FAVORITES_PREFS, MODE_PRIVATE) val selected = app.catalogFavorites.contains(book)
.getStringSet(FAVORITES_KEY, emptySet())
.orEmpty()
.contains(book.id)
binding.favoriteButton.text = if (selected) "♥" else "♡" binding.favoriteButton.text = if (selected) "♥" else "♡"
binding.favoriteButton.contentDescription = getString( binding.favoriteButton.contentDescription = getString(
if (selected) R.string.book_detail_favorite_remove else R.string.book_detail_favorite_add if (selected) R.string.book_detail_favorite_remove else R.string.book_detail_favorite_add)
)
} }
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
@@ -15,6 +15,15 @@ class QBooksCatalogAdapter(
) : RecyclerView.Adapter<QBooksCatalogAdapter.QBooksBookViewHolder>() { ) : RecyclerView.Adapter<QBooksCatalogAdapter.QBooksBookViewHolder>() {
private val items = mutableListOf<QBooksBook>() private val items = mutableListOf<QBooksBook>()
private var downloads = emptyMap<String, com.aletheia.app.data.DownloadState>()
fun setDownloads(states: Map<String, com.aletheia.app.data.DownloadState>) {
val old = downloads
downloads = states
items.forEachIndexed { index, book ->
val key = book.catalogSource + "\n" + book.id
if (old[key] != states[key]) notifyItemChanged(index)
}
}
fun submitList(books: List<QBooksBook>) { fun submitList(books: List<QBooksBook>) {
val oldItems = items.toList() val oldItems = items.toList()
@@ -22,7 +31,7 @@ class QBooksCatalogAdapter(
override fun getOldListSize(): Int = oldItems.size override fun getOldListSize(): Int = oldItems.size
override fun getNewListSize(): Int = books.size override fun getNewListSize(): Int = books.size
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean = override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean =
oldItems[oldItemPosition].id == books[newItemPosition].id oldItems[oldItemPosition].identity() == books[newItemPosition].identity()
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean = override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean =
oldItems[oldItemPosition].hasSameVisibleContent(books[newItemPosition]) oldItems[oldItemPosition].hasSameVisibleContent(books[newItemPosition])
@@ -34,9 +43,9 @@ class QBooksCatalogAdapter(
} }
fun appendDistinct(books: List<QBooksBook>) { fun appendDistinct(books: List<QBooksBook>) {
val existingIds = items.map { it.id }.toHashSet() val existingIds = items.map { it.identity() }.toHashSet()
val startIndex = items.size val startIndex = items.size
val newItems = books.filter { existingIds.add(it.id) } val newItems = books.filter { existingIds.add(it.identity()) }
if (newItems.isEmpty()) { if (newItems.isEmpty()) {
return return
} }
@@ -48,7 +57,7 @@ class QBooksCatalogAdapter(
fun appendOrUpdate(books: List<QBooksBook>) { fun appendOrUpdate(books: List<QBooksBook>) {
val merged = items.toMutableList() val merged = items.toMutableList()
books.forEach { book -> books.forEach { book ->
val index = merged.indexOfFirst { it.id == book.id } val index = merged.indexOfFirst { it.identity() == book.identity() }
if (index >= 0) merged[index] = book else merged += book if (index >= 0) merged[index] = book else merged += book
} }
submitList(merged) submitList(merged)
@@ -64,7 +73,7 @@ class QBooksCatalogAdapter(
} }
fun updateBook(book: QBooksBook) { fun updateBook(book: QBooksBook) {
val index = items.indexOfFirst { it.id == book.id } val index = items.indexOfFirst { it.identity() == book.identity() }
if (index < 0) return if (index < 0) return
items[index] = book items[index] = book
notifyItemChanged(index) notifyItemChanged(index)
@@ -101,11 +110,24 @@ class QBooksCatalogAdapter(
R.string.a11y_download_qbooks_book, R.string.a11y_download_qbooks_book,
book.title book.title
) )
val state = downloads[book.catalogSource + "\n" + book.id]
binding.downloadButton.isEnabled = state?.running != true && book.catalogSource != "legacy"
binding.downloadProgress.visibility = if (state?.running == true) android.view.View.VISIBLE else android.view.View.GONE
binding.downloadProgress.progress = state?.progress ?: 0
binding.downloadButton.setIconResource(if (state?.bookId != null) R.drawable.ic_nav_reader else R.drawable.ic_download)
binding.downloadButton.contentDescription = when {
state?.running == true -> "Загрузка ${book.title}: ${state.progress}%"
state?.bookId != null -> "Читать ${book.title}"
state?.error != null -> "Повторить загрузку ${book.title}: ${state.error}"
else -> binding.downloadButton.contentDescription
}
binding.root.setOnClickListener { onBookClicked(book) } binding.root.setOnClickListener { onBookClicked(book) }
binding.downloadButton.setOnClickListener { onDownloadClicked(book) } binding.downloadButton.setOnClickListener { onDownloadClicked(book) }
} }
} }
private fun QBooksBook.identity() = catalogSource + "\n" + id
private fun QBooksBook.hasSameVisibleContent(other: QBooksBook): Boolean = private fun QBooksBook.hasSameVisibleContent(other: QBooksBook): Boolean =
title == other.title && title == other.title &&
author == other.author && author == other.author &&
@@ -42,8 +42,11 @@ class QBooksLibraryFragment : Fragment() {
private var searchJob: Job? = null private var searchJob: Job? = null
private var loadJob: Job? = null private var loadJob: Job? = null
private val coverJobs = mutableListOf<Job>() private val coverJobs = mutableListOf<Job>()
private val activeDownloads = mutableSetOf<String>() private var catalogMode = "all"
private var browseUrl: String? = null
private var requestGeneration = 0 private var requestGeneration = 0
private var navigationJob: Job? = null
private var configuredSource: String? = null
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, state: Bundle?): View { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, state: Bundle?): View {
_binding = FragmentQbooksLibraryBinding.inflate(inflater, container, false) _binding = FragmentQbooksLibraryBinding.inflate(inflater, container, false)
@@ -82,6 +85,24 @@ class QBooksLibraryFragment : Fragment() {
binding.refreshCatalogButton.setOnClickListener { loadBooks(reset = true, forceRefresh = true) } binding.refreshCatalogButton.setOnClickListener { loadBooks(reset = true, forceRefresh = true) }
binding.swipeRefresh.setOnRefreshListener { loadBooks(reset = true, forceRefresh = true) } binding.swipeRefresh.setOnRefreshListener { loadBooks(reset = true, forceRefresh = true) }
catalogMode = state?.getString("catalog.mode") ?: "all"
browseUrl = state?.getString("catalog.url")
binding.catalogModeButton.text = state?.getString("catalog.label") ?: "Все книги"
binding.catalogModeButton.setOnClickListener {
val modes = arrayOf("all", "genres", "authors", "new", "selection", "favorites")
MaterialAlertDialogBuilder(requireContext()).setTitle("Раздел каталога")
.setItems(arrayOf("Все книги", "Жанры", "Авторы", "Новинки", "Подборка из каталога", "Избранное")) { _, index ->
selectMode(modes[index])
}.show()
}
parentFragmentManager.setFragmentResultListener("catalog-mode", viewLifecycleOwner) { _, result ->
selectMode(result.getString("mode") ?: "all")
}
viewLifecycleOwner.lifecycleScope.launch {
app.bookDownloads.states.collect { states ->
if (_binding != null) booksAdapter.setDownloads(states)
}
}
val genreChips = mapOf( val genreChips = mapOf(
binding.genreFantasy to R.string.genre_fantasy, binding.genreFantasy to R.string.genre_fantasy,
binding.genreDetective to R.string.genre_detective, binding.genreDetective to R.string.genre_detective,
@@ -91,24 +112,99 @@ class QBooksLibraryFragment : Fragment() {
binding.genreScience to R.string.genre_science binding.genreScience to R.string.genre_science
) )
genreChips.forEach { (chip, title) -> genreChips.forEach { (chip, title) ->
chip.setOnClickListener { binding.searchInput.setText(getString(title)) } chip.setOnClickListener { selectMode("genres", getString(title)) }
} }
} }
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
initializeCatalog() initializeCatalog()
app.bookDownloads.refresh(booksAdapter.currentItems())
if (catalogMode == "favorites") loadBooks(reset = true)
} }
override fun onDestroyView() { override fun onDestroyView() {
searchJob?.cancel() searchJob?.cancel()
loadJob?.cancel() loadJob?.cancel()
navigationJob?.cancel()
coverJobs.forEach { it.cancel() } coverJobs.forEach { it.cancel() }
coverJobs.clear() coverJobs.clear()
_binding = null _binding = null
super.onDestroyView() super.onDestroyView()
} }
override fun onSaveInstanceState(outState: Bundle) {
outState.putString("catalog.mode", catalogMode)
outState.putString("catalog.url", browseUrl)
outState.putString("catalog.label", binding.catalogModeButton.text.toString())
super.onSaveInstanceState(outState)
}
private fun selectMode(mode: String, genre: String? = null) {
navigationJob?.cancel()
requestGeneration++
loadJob?.cancel()
coverJobs.forEach { it.cancel() }
isLoading = false
catalogMode = mode
browseUrl = null
binding.searchInput.setText("")
searchJob?.cancel()
binding.catalogModeButton.text = when (mode) {
"genres" -> "Жанры"; "authors" -> "Авторы"; "new" -> "Новинки"
"selection" -> "Подборка из каталога"; "favorites" -> "Избранное"; else -> "Все книги"
}
binding.searchInput.hint = if (mode == "authors") "Имя или фамилия автора" else getString(R.string.label_search_qbooks)
if (mode in listOf("genres", "authors", "new")) {
navigationJob = viewLifecycleOwner.lifecycleScope.launch {
try {
val entries = app.qBooksService.navigation()
val pattern = when (mode) { "genres" -> Regex("жанр|genre", RegexOption.IGNORE_CASE)
"authors" -> Regex("автор|author", RegexOption.IGNORE_CASE)
else -> Regex("нов|new|recent", RegexOption.IGNORE_CASE) }
val entry = entries.firstOrNull { pattern.containsMatchIn(it.title) }
if (entry != null) openSection(entry.title, entry.url, genre)
else {
loadBooks(reset = true)
com.google.android.material.snackbar.Snackbar.make(binding.root,
"Каталог не предоставляет этот раздел. Доступен поиск по книгам.", 5000).show()
}
} catch (error: Exception) {
if (error is kotlinx.coroutines.CancellationException) throw error
showMessage("Не удалось загрузить раздел: ${error.message}")
}
}
} else loadBooks(reset = true)
}
private suspend fun openSection(title: String, url: String, genre: String? = null) {
val page = app.qBooksService.getCatalogPage(pageUrl = url)
if (_binding == null) return
if (page is Result.Success && page.value.books.isNotEmpty()) {
browseUrl = url
binding.catalogModeButton.text = title
loadBooks(reset = true)
} else {
val entries = app.qBooksService.navigation(url)
if (_binding == null) return
if (entries.isEmpty()) { showMessage("В разделе пока нет книг"); return }
val selectedGenre = genre?.let { wanted -> entries.firstOrNull {
it.title.contains(wanted, true) || wanted.contains(it.title, true)
} }
if (selectedGenre != null) { openSection(selectedGenre.title, selectedGenre.url); return }
MaterialAlertDialogBuilder(requireContext()).setTitle(title)
.setItems(entries.map { it.title }.toTypedArray()) { _, index ->
viewLifecycleOwner.lifecycleScope.launch {
try { openSection(entries[index].title, entries[index].url) }
catch (error: Exception) {
if (error is kotlinx.coroutines.CancellationException) throw error
showMessage("Не удалось открыть раздел: ${error.message}")
}
}
}.setNegativeButton(R.string.action_cancel, null).show()
}
}
private fun initializeCatalog() { private fun initializeCatalog() {
var url = app.settingsRepository.getCatalogUrl() var url = app.settingsRepository.getCatalogUrl()
var validation = QBooksUrlPolicy.validate(url) var validation = QBooksUrlPolicy.validate(url)
@@ -123,13 +219,21 @@ class QBooksLibraryFragment : Fragment() {
return return
} }
app.qBooksService.configure(url, null, null) app.qBooksService.configure(url, null, null)
val changed = configuredSource != null && configuredSource != url
configuredSource = url
if (changed) {
navigationJob?.cancel()
browseUrl = null
catalogMode = "all"
binding.catalogModeButton.text = "Все книги"
}
isConfigured = true isConfigured = true
binding.notConfiguredCard.visibility = View.GONE binding.notConfiguredCard.visibility = View.GONE
binding.errorCard.visibility = View.GONE binding.errorCard.visibility = View.GONE
binding.searchCard.visibility = View.VISIBLE binding.searchCard.visibility = View.VISIBLE
binding.qbooksActionsRow.visibility = View.VISIBLE binding.qbooksActionsRow.visibility = View.VISIBLE
binding.swipeRefresh.visibility = View.VISIBLE binding.swipeRefresh.visibility = View.VISIBLE
if (booksAdapter.itemCount == 0) loadBooks(reset = true) if (changed || booksAdapter.itemCount == 0) loadBooks(reset = true)
} }
private fun loadBooks(reset: Boolean, forceRefresh: Boolean = false) { private fun loadBooks(reset: Boolean, forceRefresh: Boolean = false) {
@@ -151,8 +255,20 @@ class QBooksLibraryFragment : Fragment() {
val generation = requestGeneration val generation = requestGeneration
val pageToLoad = currentPage val pageToLoad = currentPage
val pageUrl = if (reset) null else nextPageUrl val pageUrl = if (reset) browseUrl else nextPageUrl
val query = currentSearchQuery() val query = currentSearchQuery()
if (catalogMode == "favorites") {
val favorites = app.catalogFavorites.all().filter {
query.isBlank() || it.title.contains(query, true) || it.author.contains(query, true)
}
booksAdapter.submitList(favorites)
app.bookDownloads.refresh(favorites)
hasMoreBooks = false
isLoading = false
binding.swipeRefresh.isRefreshing = false
renderLoaded()
return
}
isLoading = true isLoading = true
loadJob = viewLifecycleOwner.lifecycleScope.launch { loadJob = viewLifecycleOwner.lifecycleScope.launch {
val cached = app.qBooksService.getCachedCatalogPage(query, pageToLoad, PAGE_SIZE, pageUrl) val cached = app.qBooksService.getCachedCatalogPage(query, pageToLoad, PAGE_SIZE, pageUrl)
@@ -195,7 +311,14 @@ class QBooksLibraryFragment : Fragment() {
pageIndex: Int, pageIndex: Int,
generation: Int generation: Int
) { ) {
if (reset) booksAdapter.submitList(page.books) else booksAdapter.appendOrUpdate(page.books) val visible = if (catalogMode == "selection") page.books.sortedBy {
(it.id + (System.currentTimeMillis() / 86400000L)).hashCode()
} else if (catalogMode == "authors" && currentSearchQuery().isNotBlank()) page.books.filter {
it.author.contains(currentSearchQuery(), ignoreCase = true)
} else page.books
if (reset) booksAdapter.submitList(visible) else booksAdapter.appendOrUpdate(visible)
app.catalogFavorites.recoverLegacy(page.books)
app.bookDownloads.refresh(visible)
loadMetadataAndCovers(page.books, generation) loadMetadataAndCovers(page.books, generation)
nextPageUrl = page.nextPageUrl nextPageUrl = page.nextPageUrl
hasMoreBooks = !page.nextPageUrl.isNullOrBlank() hasMoreBooks = !page.nextPageUrl.isNullOrBlank()
@@ -253,48 +376,18 @@ class QBooksLibraryFragment : Fragment() {
} }
private fun downloadBook(book: QBooksBook) { private fun downloadBook(book: QBooksBook) {
if (!activeDownloads.add(book.id)) return
setDownloadStatus(getString(R.string.qbooks_download_status, book.title), true)
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
val result = withContext(Dispatchers.IO) { val existing = withContext(Dispatchers.IO) {
runCatching { app.bookRepository.getBookByRemoteId(book.id, book.catalogSource.ifBlank { app.bookRepository.catalogSource() })
app.bookRepository.getBookByRemoteId(book.id)?.let { return@runCatching it }
val progress: (Int) -> Unit = { value ->
view?.post { setDownloadStatus(getString(R.string.qbooks_download_progress, value.toDouble()), true) }
}
val temp = when (val download = app.qBooksService.downloadBook(
book,
app.cacheDir,
progress
)) {
is Result.Success -> download.value
is Result.Failure -> error(download.message)
}
try {
app.bookRepository.importDownloadedBook(
temp,
buildDownloadFileName(book),
book.id,
book.coverImage
)
} finally {
temp.takeIf(File::exists)?.delete()
}
}
} }
activeDownloads.remove(book.id) if (existing != null) { openDownloadedBook(existing); return@launch }
if (_binding == null) return@launch try {
result.onSuccess { downloaded -> val downloaded = app.bookDownloads.start(book).await()
setDownloadStatus(getString(R.string.qbooks_download_complete), false) com.google.android.material.snackbar.Snackbar.make(binding.root, R.string.qbooks_download_complete, 6000)
MaterialAlertDialogBuilder(requireContext()) .setAction(R.string.action_read) { openDownloadedBook(downloaded) }.show()
.setTitle(R.string.dialog_ready_title) } catch (error: Exception) {
.setMessage(getString(R.string.dialog_book_added_message, book.title)) if (error is kotlinx.coroutines.CancellationException) throw error
.setPositiveButton(R.string.action_read) { _, _ -> openDownloadedBook(downloaded) } if (_binding != null) showMessage(error.message ?: "Не удалось скачать книгу")
.setNegativeButton(R.string.action_ok, null)
.show()
}.onFailure {
setDownloadStatus("", false)
showMessage(getString(R.string.dialog_download_book_failed, it.message ?: it.javaClass.simpleName))
} }
} }
} }
@@ -903,21 +903,8 @@ class ReaderActivity : AppCompatActivity() {
binding.colorPinkButton.isSelected = pendingHighlightColor == COLOR_PINK binding.colorPinkButton.isSelected = pendingHighlightColor == COLOR_PINK
} }
private fun shareSelection() { private fun shareSelection() { currentSelection?.let { selectionActions.share(it.text) } }
val selection = currentSelection ?: return private fun copySelection() { currentSelection?.let { selectionActions.copy(it.text) } }
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, selection.text)
}
startActivity(Intent.createChooser(intent, "Поделиться цитатой"))
}
private fun copySelection() {
val selection = currentSelection ?: return
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("Цитата", selection.text))
Toast.makeText(this, "Цитата скопирована", Toast.LENGTH_SHORT).show()
}
private fun openNoteEditor() { private fun openNoteEditor() {
val selection = currentSelection ?: return val selection = currentSelection ?: return
@@ -1350,42 +1337,10 @@ class ReaderActivity : AppCompatActivity() {
} }
} }
private suspend fun persistPreferences(snapshot: ReaderPreferences) { private val persistence by lazy { ReaderPersistence(app) }
withContext(Dispatchers.IO) { private val selectionActions by lazy { ReaderSelectionActions(this) }
app.readerPersistenceMutex.withLock { private suspend fun persistPreferences(snapshot: ReaderPreferences) = persistence.preferences(snapshot)
snapshot.persist(app.settingsRepository) private suspend fun persistPosition(snapshot: ReaderPosition, book: Book) = persistence.position(book.id, snapshot)
}
}
}
private suspend fun persistPosition(snapshot: ReaderPosition, book: Book) {
withContext(Dispatchers.IO) {
app.readerPersistenceMutex.withLock {
val effectiveSnapshot = app.readerStateCoordinator.latest(book.id)?.toReaderPosition()
?: snapshot
val signature = listOf(
book.id,
effectiveSnapshot.progress,
effectiveSnapshot.locator,
effectiveSnapshot.chapter,
effectiveSnapshot.currentPage,
effectiveSnapshot.totalPages
).joinToString("|")
if (signature == app.readerStateCoordinator.lastPersistedSignature(book.id)) {
return@withLock
}
app.bookRepository.saveProgress(
bookId = book.id,
progress = effectiveSnapshot.progress,
cfi = effectiveSnapshot.locator,
chapter = effectiveSnapshot.chapter,
currentPage = effectiveSnapshot.currentPage,
totalPages = effectiveSnapshot.totalPages
)
app.readerStateCoordinator.markPersisted(book.id, signature)
}
}
}
private fun persistStateThenApplyOrientation() { private fun persistStateThenApplyOrientation() {
val oldSettingsJob = settingsSaveJob val oldSettingsJob = settingsSaveJob
@@ -1639,16 +1594,6 @@ class ReaderActivity : AppCompatActivity() {
} }
} }
private data class ReaderPosition(
val progress: Double = 0.0,
val locator: String? = null,
val chapter: String? = null,
val currentPage: Int = 1,
val totalPages: Int = 1,
val chapterCurrentPage: Int = 1,
val chapterTotalPages: Int = 1
)
private data class Insets( private data class Insets(
val left: Int = 0, val left: Int = 0,
val top: Int = 0, val top: Int = 0,
@@ -0,0 +1,33 @@
package com.aletheia.app.ui.reader
import com.aletheia.app.AletheiaApplication
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
internal data class ReaderPosition(
val progress: Double = 0.0, val locator: String? = null, val chapter: String? = null,
val currentPage: Int = 1, val totalPages: Int = 1,
val chapterCurrentPage: Int = 1, val chapterTotalPages: Int = 1
)
/** Persistence owns serialization and always resolves the newest session revision. */
internal class ReaderPersistence(private val app: AletheiaApplication) {
suspend fun preferences(snapshot: ReaderPreferences) = withContext(Dispatchers.IO) {
app.readerPersistenceMutex.withLock { snapshot.persist(app.settingsRepository) }
}
suspend fun position(bookId: Long, fallback: ReaderPosition) = withContext(Dispatchers.IO) {
app.readerPersistenceMutex.withLock {
val latest = app.readerStateCoordinator.latest(bookId)
val current = latest?.let { ReaderPosition(it.progress, it.locator, it.chapter,
it.currentPage, it.totalPages, it.chapterCurrentPage, it.chapterTotalPages) } ?: fallback
val signature = listOf(bookId, current.progress, current.locator, current.chapter,
current.currentPage, current.totalPages).joinToString("|")
if (signature != app.readerStateCoordinator.lastPersistedSignature(bookId)) {
app.bookRepository.saveProgress(bookId, current.progress, current.locator, current.chapter,
current.currentPage, current.totalPages)
app.readerStateCoordinator.markPersisted(bookId, signature)
}
}
}
}
@@ -0,0 +1,20 @@
package com.aletheia.app.ui.reader
import android.app.Activity
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.widget.Toast
internal class ReaderSelectionActions(private val activity: Activity) {
fun share(text: String) = activity.startActivity(Intent.createChooser(Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, text)
}, "Поделиться цитатой"))
fun copy(text: String) {
(activity.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager)
.setPrimaryClip(ClipData.newPlainText("Цитата", text))
Toast.makeText(activity, "Цитата скопирована", Toast.LENGTH_SHORT).show()
}
}
@@ -83,6 +83,7 @@ class SettingsFragment : Fragment() {
binding.brightnessValueText.text = getString(R.string.reader_brightness_value, value.toDouble()) binding.brightnessValueText.text = getString(R.string.reader_brightness_value, value.toDouble())
} }
binding.audioPairButton.setOnClickListener { pairAudioService() }
binding.testConnectionButton.setOnClickListener { testConnection() } binding.testConnectionButton.setOnClickListener { testConnection() }
binding.saveSettingsButton.setOnClickListener { saveSettings(showNotification = true) } binding.saveSettingsButton.setOnClickListener { saveSettings(showNotification = true) }
binding.checkUpdatesButton.setOnClickListener { checkForUpdates() } binding.checkUpdatesButton.setOnClickListener { checkForUpdates() }
@@ -110,6 +111,34 @@ class SettingsFragment : Fragment() {
super.onDestroyView() super.onDestroyView()
} }
private fun pairAudioService() {
val input = com.google.android.material.textfield.TextInputEditText(requireContext()).apply {
hint = "Одноразовый код подключения"
inputType = android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD
setPadding(32, 24, 32, 24)
}
MaterialAlertDialogBuilder(requireContext()).setTitle(R.string.audio_pair)
.setMessage("Введите одноразовый код от владельца аудиосервиса. Это устройство получит отдельный доступ.")
.setView(input).setNegativeButton(R.string.action_cancel, null)
.setPositiveButton("Подключить") { _, _ ->
val code = input.text?.toString().orEmpty()
val context = requireContext().applicationContext
viewLifecycleOwner.lifecycleScope.launch {
try {
withContext(Dispatchers.IO) {
com.aletheia.app.audiobook.RemoteAudioBookClient(context).pair(code, android.os.Build.MODEL)
app.audioJobCleanup.flush()
}
binding.audioPairButton.text = "Аудиосервис подключён · подключить заново"
} catch (error: Exception) {
if (error is kotlinx.coroutines.CancellationException) throw error
MaterialAlertDialogBuilder(requireContext()).setTitle("Не удалось подключиться")
.setMessage(error.message).setPositiveButton(R.string.action_ok, null).show()
}
}
}.show()
}
private fun loadSettings() { private fun loadSettings() {
val settings = app.settingsRepository val settings = app.settingsRepository
val storedTheme = settings.getString(SettingsRepository.KEY_THEME, "sepia") val storedTheme = settings.getString(SettingsRepository.KEY_THEME, "sepia")
+23 -23
View File
@@ -181,7 +181,7 @@
<ImageButton <ImageButton
android:id="@+id/reader_back_button" android:id="@+id/reader_back_button"
android:layout_width="56dp" android:layout_width="56dp"
android:layout_height="match_parent" android:layout_height="56dp"
android:background="?attr/selectableItemBackgroundBorderless" android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/reader_v2_back" android:contentDescription="@string/reader_v2_back"
android:padding="17dp" android:padding="17dp"
@@ -377,7 +377,7 @@
<ImageButton <ImageButton
android:id="@+id/settings_back_button" android:id="@+id/settings_back_button"
android:layout_width="48dp" android:layout_width="48dp"
android:layout_height="match_parent" android:layout_height="48dp"
android:background="?attr/selectableItemBackgroundBorderless" android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/reader_v2_back" android:contentDescription="@string/reader_v2_back"
android:padding="13dp" android:padding="13dp"
@@ -397,7 +397,7 @@
<ImageButton <ImageButton
android:id="@+id/settings_close_button" android:id="@+id/settings_close_button"
android:layout_width="48dp" android:layout_width="48dp"
android:layout_height="match_parent" android:layout_height="48dp"
android:background="?attr/selectableItemBackgroundBorderless" android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/reader_v2_close" android:contentDescription="@string/reader_v2_close"
android:padding="13dp" android:padding="13dp"
@@ -509,7 +509,7 @@
android:layout_marginEnd="2dp" android:layout_marginEnd="2dp"
android:layout_weight="1" android:layout_weight="1"
android:background="@drawable/bg_reader_v2_theme_light" android:background="@drawable/bg_reader_v2_theme_light"
android:minWidth="0dp" android:minWidth="48dp"
android:fontFamily="sans" android:fontFamily="sans"
android:maxLines="1" android:maxLines="1"
android:singleLine="true" android:singleLine="true"
@@ -535,7 +535,7 @@
android:layout_marginEnd="2dp" android:layout_marginEnd="2dp"
android:layout_weight="1" android:layout_weight="1"
android:background="@drawable/bg_reader_v2_theme_sepia" android:background="@drawable/bg_reader_v2_theme_sepia"
android:minWidth="0dp" android:minWidth="48dp"
android:fontFamily="sans" android:fontFamily="sans"
android:maxLines="1" android:maxLines="1"
android:singleLine="true" android:singleLine="true"
@@ -560,7 +560,7 @@
android:layout_marginStart="2dp" android:layout_marginStart="2dp"
android:layout_weight="1" android:layout_weight="1"
android:background="@drawable/bg_reader_v2_theme_dark" android:background="@drawable/bg_reader_v2_theme_dark"
android:minWidth="0dp" android:minWidth="48dp"
android:fontFamily="sans" android:fontFamily="sans"
android:maxLines="1" android:maxLines="1"
android:singleLine="true" android:singleLine="true"
@@ -1335,7 +1335,7 @@
android:background="@drawable/bg_reader_v2_theme_light" android:background="@drawable/bg_reader_v2_theme_light"
android:fontFamily="sans" android:fontFamily="sans"
android:maxLines="1" android:maxLines="1"
android:minWidth="0dp" android:minWidth="48dp"
android:singleLine="true" android:singleLine="true"
android:paddingStart="8dp" android:paddingStart="8dp"
android:paddingEnd="8dp" android:paddingEnd="8dp"
@@ -1361,7 +1361,7 @@
android:background="@drawable/bg_reader_v2_theme_sepia" android:background="@drawable/bg_reader_v2_theme_sepia"
android:fontFamily="sans" android:fontFamily="sans"
android:maxLines="1" android:maxLines="1"
android:minWidth="0dp" android:minWidth="48dp"
android:singleLine="true" android:singleLine="true"
android:paddingStart="8dp" android:paddingStart="8dp"
android:paddingEnd="8dp" android:paddingEnd="8dp"
@@ -1386,7 +1386,7 @@
android:background="@drawable/bg_reader_v2_theme_dark" android:background="@drawable/bg_reader_v2_theme_dark"
android:fontFamily="sans" android:fontFamily="sans"
android:maxLines="1" android:maxLines="1"
android:minWidth="0dp" android:minWidth="48dp"
android:singleLine="true" android:singleLine="true"
android:paddingStart="8dp" android:paddingStart="8dp"
android:paddingEnd="8dp" android:paddingEnd="8dp"
@@ -1418,7 +1418,7 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="52dp" android:layout_height="52dp"
android:background="@drawable/bg_reader_v2_mode_selectable" android:background="@drawable/bg_reader_v2_mode_selectable"
android:minWidth="0dp" android:minWidth="48dp"
android:text="@string/reader_v2_page_turn_tap_swipe" android:text="@string/reader_v2_page_turn_tap_swipe"
android:textAllCaps="false" android:textAllCaps="false"
android:textColor="@color/reader_v2_text" android:textColor="@color/reader_v2_text"
@@ -1431,7 +1431,7 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="52dp" android:layout_height="52dp"
android:background="@drawable/bg_reader_v2_mode_selectable" android:background="@drawable/bg_reader_v2_mode_selectable"
android:minWidth="0dp" android:minWidth="48dp"
android:text="@string/reader_v2_page_turn_swipe" android:text="@string/reader_v2_page_turn_swipe"
android:textAllCaps="false" android:textAllCaps="false"
android:textColor="@color/reader_v2_text" android:textColor="@color/reader_v2_text"
@@ -1444,7 +1444,7 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="52dp" android:layout_height="52dp"
android:background="@drawable/bg_reader_v2_mode_selectable" android:background="@drawable/bg_reader_v2_mode_selectable"
android:minWidth="0dp" android:minWidth="48dp"
android:text="@string/reader_v2_page_turn_tap" android:text="@string/reader_v2_page_turn_tap"
android:textAllCaps="false" android:textAllCaps="false"
android:textColor="@color/reader_v2_text" android:textColor="@color/reader_v2_text"
@@ -1517,7 +1517,7 @@
android:layout_height="match_parent" android:layout_height="match_parent"
android:layout_weight="1" android:layout_weight="1"
android:background="@drawable/bg_reader_v2_tab" android:background="@drawable/bg_reader_v2_tab"
android:minWidth="0dp" android:minWidth="48dp"
android:text="@string/reader_v2_tab_toc" android:text="@string/reader_v2_tab_toc"
android:textColor="@color/reader_v2_text" android:textColor="@color/reader_v2_text"
android:textSize="12sp" android:textSize="12sp"
@@ -1530,7 +1530,7 @@
android:layout_height="match_parent" android:layout_height="match_parent"
android:layout_weight="1" android:layout_weight="1"
android:background="@drawable/bg_reader_v2_tab" android:background="@drawable/bg_reader_v2_tab"
android:minWidth="0dp" android:minWidth="48dp"
android:text="@string/reader_v2_tab_bookmarks" android:text="@string/reader_v2_tab_bookmarks"
android:textColor="@color/reader_v2_secondary_on_light" android:textColor="@color/reader_v2_secondary_on_light"
android:textSize="12sp" android:textSize="12sp"
@@ -1543,7 +1543,7 @@
android:layout_height="match_parent" android:layout_height="match_parent"
android:layout_weight="1" android:layout_weight="1"
android:background="@drawable/bg_reader_v2_tab" android:background="@drawable/bg_reader_v2_tab"
android:minWidth="0dp" android:minWidth="48dp"
android:text="@string/reader_v2_tab_quotes" android:text="@string/reader_v2_tab_quotes"
android:textColor="@color/reader_v2_secondary_on_light" android:textColor="@color/reader_v2_secondary_on_light"
android:textSize="12sp" android:textSize="12sp"
@@ -1553,7 +1553,7 @@
<ImageButton <ImageButton
android:id="@+id/contents_close_button" android:id="@+id/contents_close_button"
android:layout_width="48dp" android:layout_width="48dp"
android:layout_height="match_parent" android:layout_height="58dp"
android:background="?attr/selectableItemBackgroundBorderless" android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/reader_v2_close" android:contentDescription="@string/reader_v2_close"
android:padding="13dp" android:padding="13dp"
@@ -1703,7 +1703,7 @@
<ImageButton <ImageButton
android:id="@+id/selection_close_button" android:id="@+id/selection_close_button"
android:layout_width="52dp" android:layout_width="52dp"
android:layout_height="match_parent" android:layout_height="52dp"
android:background="?attr/selectableItemBackgroundBorderless" android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/reader_v2_close" android:contentDescription="@string/reader_v2_close"
android:padding="14dp" android:padding="14dp"
@@ -1715,7 +1715,7 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="56dp" android:layout_height="56dp"
android:gravity="start|center_vertical" android:gravity="start|center_vertical"
android:minWidth="0dp" android:minWidth="48dp"
android:paddingStart="20dp" android:paddingStart="20dp"
android:text="@string/reader_v2_share" android:text="@string/reader_v2_share"
android:textAllCaps="false" android:textAllCaps="false"
@@ -1738,7 +1738,7 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="56dp" android:layout_height="56dp"
android:gravity="start|center_vertical" android:gravity="start|center_vertical"
android:minWidth="0dp" android:minWidth="48dp"
android:paddingStart="20dp" android:paddingStart="20dp"
android:text="@string/reader_v2_copy" android:text="@string/reader_v2_copy"
android:textAllCaps="false" android:textAllCaps="false"
@@ -1761,7 +1761,7 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="56dp" android:layout_height="56dp"
android:gravity="start|center_vertical" android:gravity="start|center_vertical"
android:minWidth="0dp" android:minWidth="48dp"
android:paddingStart="20dp" android:paddingStart="20dp"
android:text="@string/reader_v2_add_note" android:text="@string/reader_v2_add_note"
android:textAllCaps="false" android:textAllCaps="false"
@@ -1858,7 +1858,7 @@
android:id="@+id/save_quote_button" android:id="@+id/save_quote_button"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="56dp" android:layout_height="56dp"
android:minWidth="0dp" android:minWidth="48dp"
android:text="@string/reader_v2_save_quote" android:text="@string/reader_v2_save_quote"
android:textColor="@color/reader_v2_text" android:textColor="@color/reader_v2_text"
android:textSize="16sp" android:textSize="16sp"
@@ -1967,7 +1967,7 @@
android:layout_height="match_parent" android:layout_height="match_parent"
android:layout_marginEnd="6dp" android:layout_marginEnd="6dp"
android:layout_weight="1" android:layout_weight="1"
android:minWidth="0dp" android:minWidth="48dp"
android:text="@string/reader_v2_cancel" android:text="@string/reader_v2_cancel"
android:textColor="@color/reader_v2_primary" android:textColor="@color/reader_v2_primary"
app:backgroundTint="@color/reader_v2_separator" app:backgroundTint="@color/reader_v2_separator"
@@ -1979,7 +1979,7 @@
android:layout_height="match_parent" android:layout_height="match_parent"
android:layout_marginStart="6dp" android:layout_marginStart="6dp"
android:layout_weight="1" android:layout_weight="1"
android:minWidth="0dp" android:minWidth="48dp"
android:text="@string/reader_v2_save" android:text="@string/reader_v2_save"
android:textColor="@color/reader_v2_canvas" android:textColor="@color/reader_v2_canvas"
app:backgroundTint="@color/reader_v2_accent" app:backgroundTint="@color/reader_v2_accent"
@@ -153,6 +153,40 @@
</LinearLayout> </LinearLayout>
</com.google.android.material.card.MaterialCardView> </com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:id="@+id/audio_bookshelf_panel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:visibility="gone"
app:cardBackgroundColor="@color/white"
app:cardCornerRadius="20dp"
app:cardElevation="0dp"
app:strokeColor="@color/border_color"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="14dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fontFamily="@font/open_sans_semibold"
android:text="@string/library_audiobooks_section"
android:textColor="@color/ink_color"
android:textSize="20sp" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/audio_books_recycler"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:clipToPadding="false"
android:contentDescription="@string/library_audiobooks_section"
android:scrollbars="none" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView <com.google.android.material.card.MaterialCardView
android:id="@+id/continue_reading_card" android:id="@+id/continue_reading_card"
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -77,6 +77,15 @@
android:textColor="@color/ink_color" android:textColor="@color/ink_color"
android:textSize="18sp" /> android:textSize="18sp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/catalog_mode_button"
style="@style/Widget.Aletheia.Button.Secondary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:minHeight="48dp"
android:text="@string/catalog_mode" />
<HorizontalScrollView <HorizontalScrollView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@@ -31,6 +31,15 @@
android:lineSpacingExtra="2dp" android:lineSpacingExtra="2dp"
android:textSize="13sp" /> android:textSize="13sp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/audio_pair_button"
style="@style/Widget.Aletheia.Button.Secondary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="48dp"
android:layout_marginTop="12dp"
android:text="@string/audio_pair" />
<com.google.android.material.card.MaterialCardView <com.google.android.material.card.MaterialCardView
android:id="@+id/settings_overview_card" android:id="@+id/settings_overview_card"
android:layout_width="match_parent" android:layout_width="match_parent"
+12 -4
View File
@@ -67,16 +67,24 @@
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>
<ProgressBar
android:id="@+id/download_progress"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="32dp"
android:layout_height="8dp"
android:visibility="gone"
android:max="100" />
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/download_button" android:id="@+id/download_button"
style="@style/Widget.Aletheia.Button.Primary" style="@style/Widget.Aletheia.Button.Primary"
android:layout_width="40dp" android:layout_width="48dp"
android:layout_height="40dp" android:layout_height="48dp"
android:layout_marginStart="8dp" android:layout_marginStart="8dp"
android:insetTop="0dp" android:insetTop="0dp"
android:insetBottom="0dp" android:insetBottom="0dp"
android:minWidth="40dp" android:minWidth="48dp"
android:minHeight="40dp" android:minHeight="48dp"
android:padding="8dp" android:padding="8dp"
app:icon="@drawable/ic_download" app:icon="@drawable/ic_download"
app:iconGravity="textStart" app:iconGravity="textStart"
+3
View File
@@ -339,4 +339,7 @@
<string name="book_detail_favorite_remove">Убрать из избранного</string> <string name="book_detail_favorite_remove">Убрать из избранного</string>
<string name="share_book_chooser_title">Поделиться книгой</string> <string name="share_book_chooser_title">Поделиться книгой</string>
<string name="share_book_link_missing">Для этой книги нет доступной ссылки.</string> <string name="share_book_link_missing">Для этой книги нет доступной ссылки.</string>
<string name="catalog_mode">Все книги · выбрать раздел</string>
<string name="audio_pair">Подключить аудиосервис</string>
<string name="library_audiobooks_section">Аудиокниги</string>
</resources> </resources>
+1
View File
@@ -6,6 +6,7 @@
<exclude <exclude
domain="file" domain="file"
path="Books" /> path="Books" />
<exclude domain="file" path="Audiobooks" />
<exclude <exclude
domain="sharedpref" domain="sharedpref"
path="." /> path="." />
@@ -7,6 +7,7 @@
<exclude <exclude
domain="file" domain="file"
path="Books" /> path="Books" />
<exclude domain="file" path="Audiobooks" />
<exclude <exclude
domain="sharedpref" domain="sharedpref"
path="." /> path="." />
@@ -19,6 +20,7 @@
<exclude <exclude
domain="file" domain="file"
path="Books" /> path="Books" />
<exclude domain="file" path="Audiobooks" />
<exclude <exclude
domain="sharedpref" domain="sharedpref"
path="." /> path="." />
@@ -0,0 +1,64 @@
package com.aletheia.app.data
import com.aletheia.app.audiobook.PlaybackMath
import com.aletheia.app.audiobook.AudioHttpException
import com.aletheia.app.audiobook.AudioRetryPolicy
import java.io.File
import java.nio.charset.Charset
import org.junit.Assert.*
import org.junit.Test
class AuditRegressionTest {
@Test fun audiobookProgressCompletesOnlyAfterDownload() {
val audio = com.aletheia.app.model.AudioBook(sourceBookId = 1, title = "Test", author = "",
status = com.aletheia.app.model.AudioBook.Status.GENERATING, totalCharacters = 100, processedCharacters = 100)
assertEquals(99, audio.progressPercent)
assertEquals(100, audio.copy(status = com.aletheia.app.model.AudioBook.Status.READY).progressPercent)
assertEquals(50, audio.copy(processedCharacters = 50).progressPercent)
}
@Test fun longAudiobookSeekRemainsMonotonicAndReachesEnd() {
for (hours in listOf(1, 2, 10, 20, 100)) {
val duration = hours * 3600000L + 731
var previous = -1L
for (slider in 0..1000) {
val position = PlaybackMath.position(duration, slider)
assertTrue(position in 0..duration)
assertTrue(position >= previous)
assertTrue(kotlin.math.abs(PlaybackMath.slider(position, duration) - slider) <= 1)
previous = position
}
assertEquals(duration, previous)
}
}
@Test fun permanentHttpErrorsStopAndTemporaryFailuresRemainRetryable() {
for (status in listOf(400, 401, 403, 404, 413))
assertFalse(AudioRetryPolicy.shouldRetry(AudioHttpException(status), 1))
for (status in listOf(408, 429, 500, 502, 503)) {
assertTrue(AudioRetryPolicy.shouldRetry(AudioHttpException(status), 1))
assertTrue(AudioRetryPolicy.shouldRetry(AudioHttpException(status), 1000))
}
assertTrue(AudioRetryPolicy.shouldRetry(java.io.IOException("Network lost"), 1000))
}
@Test fun fb2XmlDeclarationControlsCyrillicDecoding() {
for (encoding in listOf("windows-1251", "UTF-16", "UTF-8")) {
val root = java.nio.file.Files.createTempDirectory("aletheia-encoding").toFile()
try {
val file = File(root, "input.fb2")
val xml = """<?xml version="1.0" encoding="$encoding"?>
<FictionBook xmlns="http://www.gribuser.ru/xml/fictionbook/2.0">
<description><title-info><book-title>Мир и чтение</book-title>
<author><first-name>Лев</first-name><last-name>Толстой</last-name></author>
</title-info></description><body><section><p>Текст</p></section></body></FictionBook>""".trimIndent()
file.writeBytes(xml.toByteArray(Charset.forName(encoding)))
val parser = BookParserService(File(root, "Books"), File(root, "Cache")) {
java.util.Base64.getMimeDecoder().decode(it)
}
val book = parser.parseAndStoreBook(file, "input.fb2")
assertEquals(encoding, "Мир и чтение", book.title)
assertEquals("Лев Толстой", book.author)
} finally { root.deleteRecursively() }
}
}
}
+51
View File
@@ -0,0 +1,51 @@
# Aletheia 2.40 — результат реализации аудита
Дата проверки: 6 сентября 2026. Android versionCode: 51.
## Реализовано
- Библиотека: фильтры остаются доступны при пустой выдаче; очистка сбрасывает запрос и выбранный фильтр (`BookshelfFragment.kt`).
- База версии 9: источник каталога, уникальность пары источник/ID, индексированный поиск; отдельные обновления метаданных, позиции чтения, карты страниц и позиции аудио (`AppDatabaseHelper.kt`, `BookRepository.kt`). Старым записям без известного источника сервер не приписывается.
- FB2 передаётся XML-парсеру исходным потоком с учётом декларации кодировки (`BookParserService.kt`).
- Общие загрузки на уровне приложения: каталог и карточка получают один результат, прогресс привязан к книге; закрытие экрана не отменяет импорт (`BookDownloads.kt`).
- Каталог: избранное с сохранёнными метаданными, настоящая навигация по доступным OPDS-разделам, подборка из каталога, различение источников в карточках. Если сервер не предоставляет раздел, приложение сообщает об этом. Старое избранное с неизвестным источником показано отдельно, без подстановки другой книги (`CatalogFavorites.kt`, `QBooksLibraryFragment.kt`).
- Плеер и таймер сна перенесены в MediaSessionService; перемотка использует безопасные вычисления; кнопки имеют смысловые подписи, таймер показывает оставшееся время (`AudioPlaybackService.kt`, `PlaybackMath.kt`, `AudioBookPlayerActivity.kt`).
- Готовые M4A сохраняются в постоянном каталоге приложения, существующие файлы переносятся из кэша; добавлен экспорт через системный диалог сохранения (`AudioBookRepository.kt`, `BookshelfFragment.kt`).
- HTTP-ошибки разделены на окончательные и временные, число повторов ограничено. Удаление сохраняет идентификатор серверного задания в устойчивую очередь очистки до подтверждения; позднее завершение загрузки не восстанавливает удалённую запись (`AudioRetryPolicy`, `AudioJobCleanup`, `AudioBookGenerationService`).
- Из APK удалён общий токен. Добавлены одноразовое подключение устройства, хранение полномочий через Android Keystore, серверная проверка владельца задания и отзыв устройств. Публичный health больше не сообщает ID задания (`AudioDeviceCredentials.kt`, `device_auth.py`, `audiobook_service.py`).
- Синтез выполняется отдельным процессом; его завершение не перезапускает HTTP API (`worker_runner.py`).
- Из ReaderActivity выделены сохранение состояния и действия с выделенным текстом; сохранены существующие ReaderStateCoordinator и ReaderWebController. Это первый этап постепенного разделения Activity, а не полная переработка всех панелей.
- Ключ подписи перенесён в `%USERPROFILE%\.aletheia\signing`, удалён из рабочего дерева Git; сценарий сборки использует внешнее хранилище. История Git не переписана и по-прежнему содержит прежние версии файлов ключа.
- Сценарии подготовки и публикации проверяют коды завершения, подпись и SHA-256. Перед публикацией выполняются резервное копирование SQLite и integrity_check.
## Проверки
| Проверка | Результат и источник |
|---|---|
| Kotlin/JVM | 38 тестов, 0 ошибок и падений; `app/build/test-results/testDebugUnitTest/TEST-*.xml`, `artifacts/release-final-2.40.log` |
| Android API 35, эмулятор | 8 тестов, PASS; `artifacts/instrumentation-2.40.log` |
| Сервер | 6 тестов, PASS; `artifacts/server-tests-2.40.log` |
| Android lint | 0 ошибок, 369 предупреждений; `app/build/reports/lint-results-debug.txt` |
| Области нажатия | `scripts/test-accessibility-layout.ps1`: PASS |
| Release | `assembleRelease`: PASS; `artifacts/release-final-2.40.log` |
| Обновление APK | Установка 2.39 → `adb install -r` 2.40 на эмуляторе: Success; запуск MainActivity: Status ok |
Восемь Android-тестов проверяют: таймер при выключенном экране; перенос аудиофайла из кэша; миграцию базы 8→9; сохранение позиции аудио при обновлении задания; сохранение позиции чтения при обновлении метаданных; независимость одинаковых ID разных источников; одну загрузку для двух экранов при отмене наблюдателя; восстановление библиотеки после пустого фильтра.
Физический телефон и TalkBack не проверялись. Полный сценарий восстановления EPUB/FB2 после поворота и смены шрифта не проверен на устройстве в этом выпуске; имеющиеся JVM-тесты проверяют парсер и координатор состояния, а не весь WebView-сценарий.
## Публикация
- Argus: stable/android, версия 2.40, пакет `aletheia-2.40-51.apk`.
- [Публичный manifest](https://argus.kusoft.xyz/api/apps/aletheia-kotlin/manifest?platform=android&channel=stable).
- [Скачать APK](https://argus.kusoft.xyz/api/apps/aletheia-kotlin/releases/0d2d1c6b-7666-4967-95e2-30876538adf3/download).
- SHA-256 локального и повторно скачанного публичного APK: `c3df0b3d0e6afeda5fe22986cbf4165fab394689c382044b927bdd7c33cdec83`.
- SHA-256 сертификата обеих версий: `f4a74d73540d3ea7f092012d50e798d0fbb9c61d3f00483163dc22b04acda764`.
- Резервная копия БД Аргуса: `/mnt/data/argus/backups/argus-before-aletheia-kotlin-2.40-0D2D1C6B-7666-4967-95E2-30876538ADF3.db`.
- Журнал публикации: `artifacts/publication-2.40.log`.
## Незавершённое развёртывание аудиосервиса
CMP `192.168.0.112` не отвечает по SSH (тайм-аут); публичный `/aletheia-tts/health` возвращает HTTP 502. Серверные изменения проверены локальными тестами, но **не развёрнуты на CMP**. Синтез на GPU и подключение реального устройства по новому протоколу не подтверждены.
Подготовлен архив `artifacts/aletheia-audiobook-service-2.40.tar.gz`. После восстановления доступа нужны резервная копия jobs.db3, обновление службы по `server/audiobook/README.md`, проверка публичного маршрута и подключение устройства одноразовым кодом. Старый общий токен новая версия APK не использует; без обновления CMP создание новых аудиокниг недоступно. Чтение и воспроизведение уже сохранённых файлов работают локально.
+6 -4
View File
@@ -188,7 +188,6 @@ function Resolve-KeystoreInfoPath {
$candidates += $PreferredPath $candidates += $PreferredPath
} }
$candidates += @( $candidates += @(
(Join-Path $ProjectRoot "signing\release-keystore-info.txt"),
(Join-Path $env:USERPROFILE ".aletheia\signing\release-keystore-info.txt") (Join-Path $env:USERPROFILE ".aletheia\signing\release-keystore-info.txt")
) )
@@ -215,7 +214,6 @@ function Resolve-KeystorePath {
$configuredLeaf = Split-Path -Leaf $ConfiguredPath $configuredLeaf = Split-Path -Leaf $ConfiguredPath
if ($configuredLeaf) { if ($configuredLeaf) {
$candidates += (Join-Path (Split-Path -Parent $KeystoreInfoPath) $configuredLeaf) $candidates += (Join-Path (Split-Path -Parent $KeystoreInfoPath) $configuredLeaf)
$candidates += (Join-Path (Join-Path $ProjectRoot "signing") $configuredLeaf)
} }
} }
@@ -275,6 +273,7 @@ $env:ANDROID_SDK_ROOT = $sdkDirPath
if (-not $SkipBuild) { if (-not $SkipBuild) {
& $gradleExePath assembleRelease --stacktrace & $gradleExePath assembleRelease --stacktrace
if ($LASTEXITCODE -ne 0) { throw "Release build failed: $LASTEXITCODE" }
} }
$outputMetadataPath = Join-Path $projectRoot "app\build\outputs\apk\release\output-metadata.json" $outputMetadataPath = Join-Path $projectRoot "app\build\outputs\apk\release\output-metadata.json"
@@ -328,9 +327,10 @@ foreach ($path in @($alignedApk, $signedApk, $idsigPath)) {
} }
& $zipalignExe -p -f 4 $unsignedApk $alignedApk & $zipalignExe -p -f 4 $unsignedApk $alignedApk
if ($LASTEXITCODE -ne 0) { throw "zipalign failed: $LASTEXITCODE" }
$storePassFile = Join-Path $env:TEMP "aletheia-store-pass.txt" $storePassFile = (New-TemporaryFile).FullName
$keyPassFile = Join-Path $env:TEMP "aletheia-key-pass.txt" $keyPassFile = (New-TemporaryFile).FullName
try { try {
Set-Content -LiteralPath $storePassFile -Value $keystoreInfo["StorePassword"] -NoNewline Set-Content -LiteralPath $storePassFile -Value $keystoreInfo["StorePassword"] -NoNewline
@@ -345,6 +345,7 @@ try {
--v4-signing-enabled true ` --v4-signing-enabled true `
--out $signedApk ` --out $signedApk `
$alignedApk $alignedApk
if ($LASTEXITCODE -ne 0) { throw "APK signing failed: $LASTEXITCODE" }
} }
finally { finally {
foreach ($secretPath in @($storePassFile, $keyPassFile)) { foreach ($secretPath in @($storePassFile, $keyPassFile)) {
@@ -358,6 +359,7 @@ finally {
} }
$verifyOutputLines = & $apksignerBat verify --verbose --print-certs --v4-signature-file $idsigPath $signedApk $verifyOutputLines = & $apksignerBat verify --verbose --print-certs --v4-signature-file $idsigPath $signedApk
if ($LASTEXITCODE -ne 0) { throw "APK verification failed: $LASTEXITCODE" }
$verifyOutput = $verifyOutputLines | Out-String $verifyOutput = $verifyOutputLines | Out-String
foreach ($requiredVerificationLine in @( foreach ($requiredVerificationLine in @(
"Verified using v2 scheme (APK Signature Scheme v2): true", "Verified using v2 scheme (APK Signature Scheme v2): true",
+16 -2
View File
@@ -1,8 +1,8 @@
param( param(
[string]$ManifestPath, [string]$ManifestPath,
[string]$SshHost = "192.168.0.25", [string]$SshHost = "192.168.0.185",
[string]$SshUser = "sevenhill", [string]$SshUser = "sevenhill",
[string]$ArgusDataPath = "/opt/argus/data", [string]$ArgusDataPath = "/mnt/data/argus",
[switch]$RestartArgus [switch]$RestartArgus
) )
@@ -206,11 +206,22 @@ shutil.copy2(source, target_path)
size_bytes = target_path.stat().st_size size_bytes = target_path.stat().st_size
sha256 = hashlib.sha256(target_path.read_bytes()).hexdigest() sha256 = hashlib.sha256(target_path.read_bytes()).hexdigest()
if sha256 != os.environ["ARGUS_SHA256"]:
target_path.unlink()
raise RuntimeError("Transferred package SHA-256 mismatch")
content_type = mimetypes.guess_type(source.name)[0] or "application/octet-stream" content_type = mimetypes.guess_type(source.name)[0] or "application/octet-stream"
if source.suffix.lower() == ".apk": if source.suffix.lower() == ".apk":
content_type = "application/vnd.android.package-archive" content_type = "application/vnd.android.package-archive"
conn = sqlite3.connect(db_path) conn = sqlite3.connect(db_path)
backup_dir = data_root / "backups"
backup_dir.mkdir(parents=True, exist_ok=True)
backup_path = backup_dir / f"argus-before-{slug}-{safe_version}-{release_id}.db"
with sqlite3.connect(backup_path) as backup:
conn.backup(backup)
if backup.execute("PRAGMA integrity_check").fetchone()[0] != "ok":
raise RuntimeError("Argus backup integrity check failed")
print(f"DatabaseBackup={backup_path}")
try: try:
conn.execute("PRAGMA foreign_keys = ON") conn.execute("PRAGMA foreign_keys = ON")
conn.execute("BEGIN") conn.execute("BEGIN")
@@ -280,6 +291,8 @@ try:
) )
conn.commit() conn.commit()
if conn.execute("PRAGMA integrity_check").fetchone()[0] != "ok":
raise RuntimeError("Argus database integrity check failed after publication")
except Exception: except Exception:
conn.rollback() conn.rollback()
try: try:
@@ -313,6 +326,7 @@ $remoteScriptLines = @(
"export ARGUS_PLATFORM=$(ConvertTo-BashSingleQuoted $platform)", "export ARGUS_PLATFORM=$(ConvertTo-BashSingleQuoted $platform)",
"export ARGUS_PACKAGE_KIND=$(ConvertTo-BashSingleQuoted $packageKind)", "export ARGUS_PACKAGE_KIND=$(ConvertTo-BashSingleQuoted $packageKind)",
"export ARGUS_NOTES=$(ConvertTo-BashSingleQuoted $releaseNotes)", "export ARGUS_NOTES=$(ConvertTo-BashSingleQuoted $releaseNotes)",
"export ARGUS_SHA256=$(ConvertTo-BashSingleQuoted $packageSha256)",
'trap ''rm -f -- "$SOURCE_FILE"'' EXIT', 'trap ''rm -f -- "$SOURCE_FILE"'' EXIT',
'python3 - "$SOURCE_FILE" <<''PY''', 'python3 - "$SOURCE_FILE" <<''PY''',
$pythonBlock, $pythonBlock,
+50 -38
View File
@@ -1,49 +1,61 @@
# Aletheia CMP audiobook receiver # Aletheia audiobook receiver for VoiceStudio on CMP
The receiver accepts EPUB/FB2 files, queues one synthesis job at a time, reports progress, and returns an AAC/M4A file. The HTTP process starts with no Qwen model in memory. `Qwen3TTSModel` and `torch` are imported only when a job reaches the synthesis stage; the model is deleted and the CUDA cache is cleared before M4A encoding. The receiver accepts EPUB and FB2, stores persistent jobs and device credentials,
extracts text, and sends it to the existing VoiceStudio `/audiobook` API.
VoiceStudio runs on three GPUs and uses the saved Russian narrator `db7928a3`.
The receiver does not load TTS models or manage other inference services.
## Deployed layout ## Deployment
- CMP host: `192.168.0.112`, service root `/home/sevenhill/apps/aletheia-audiobook` - Receiver: `/home/sevenhill/apps/aletheia-audiobook/service`
- API: `http://0.0.0.0:8765` - Python: `/home/sevenhill/apps/VoiceStudio/.venv/bin/python`
- jobs and SQLite state: `/home/sevenhill/apps/aletheia-audiobook/audiobook-jobs` - State: `/home/sevenhill/apps/aletheia-audiobook/audiobook-jobs/jobs.db3`
- API token: `/home/sevenhill/apps/aletheia-audiobook/service/service-token.txt` - VoiceStudio: `http://127.0.0.1:3900`, outputs in `~/voicestudio-data/outputs`
- public route: `https://argus.kusoft.xyz/aletheia-tts/` - Public API: `https://argus.kusoft.xyz/aletheia-tts/`
- voice: Qwen3-TTS 0.6B CustomVoice, `Ryan`, Russian - User unit: `aletheia-audiobook-cmp.service`, port 8765
The token is intentionally not stored in this repository. The Android build reads it from `%USERPROFILE%\.aletheia\audiobook-api-token.txt` and embeds it in `BuildConfig` for this private installation. Copy service Python files and the unit, then run `systemctl --user daemon-reload`
and `systemctl --user enable --now aletheia-audiobook-cmp`. User lingering enables
startup without an SSH session. Back up the SQLite database before upgrades.
The Pi Caddy route strips `/aletheia-tts/` and proxies to CMP port 8765.
## Runtime `aletheia-audio-cleanup.service` runs `audio_cleanup.py` independently, so it can
be deployed while a book is rendering. Android acknowledges delivery with DELETE
only after saving the M4A; a SQLite trigger retains that acknowledgement across
restarts. The cleaner removes the corresponding VoiceStudio M4B, work directory
and chapter WAVs, plus old disposable segment cache when all renderers are idle.
Models, voice profiles and other finished books are retained. New jobs record the
exact VoiceStudio output before becoming ready; an already-running legacy worker
is matched by SHA-256 while its output is being delivered. Unknown matches are
retained rather than guessing which book to delete.
The CMP receiver uses `/home/sevenhill/apps/qwen3-tts-venv`. It starts as a lightweight FastAPI process and loads the 0.6B Qwen model only after a queued audiobook reaches the synthesis stage. The model is deleted and CUDA cache cleared before M4A encoding. The health endpoint checks VoiceStudio availability. Device enrollment is required:
run `manage_devices.py pair` with the service Python, then enter the single-use code
in Aletheia Settings → «Подключить аудиосервис». Codes normally expire in ten minutes.
Credentials are independent per device; job status, file and deletion check ownership.
Use `manage_devices.py list` and `manage_devices.py revoke DEVICE_ID` for revocation.
The `systemd --user` service starts only the receiver process. It does not preload Qwen or reserve GPU memory; the model is loaded by an audiobook request. After a job finishes, systemd replaces the receiver process so Qwen and Triton CUDA contexts are fully released before the receiver waits for the next request. ## Processing and progress
When an audiobook enters synthesis, the receiver temporarily stops and runtime-masks Ollama, then loads one tested 0.6B Qwen worker on each of GPU 0, 1, and 2. Up to three independent chunks are generated in parallel. After the job, the receiver exits so systemd can release all CUDA contexts; its fresh idle process restores Ollama. Existing WAV chunks are never regenerated during resume. Android checks availability before resetting a book and uploading it. The receiver
consumes VoiceStudio chapter/assembly events and exposes progress through job status.
Long EPUB/FB2 sections are split at sentence/word boundaries into parts of at most
1600 characters. Each free GPU immediately takes the next part from a shared queue;
it never waits for the other two GPUs to finish a batch. Progress events are sent
after each part and counted by completed text length. Repeated events from GPU
rendering and assembly are counted once. The deployed scheduler and regression
test are mirrored in `cmp/` and installed in VoiceStudio's `backend/services/`
and `cmp/` directories respectively.
VoiceStudio's M4B MP4/AAC container is copied as M4A, preserving chapter metadata.
The app downloads it to persistent storage and reports 100% only after saving it.
Per-batch timings are appended to `audiobook-jobs\performance.jsonl`. The log separates the autoregressive talker, speech-tokenizer decoder, and wrapper time so runtime optimizations can be benchmarked without changing the generated audio path. Jobs interrupted by a receiver restart are requeued; VoiceStudio reuses its chapter
cache. Cancellation is checked between stream events; a chapter already rendering
can finish before the cancellation is observed. VoiceStudio keeps its own output/cache.
The CMP firewall must permit TCP 8765 only from the Raspberry Pi address. Caddy uses a path handler that removes the public prefix before proxying: ## Verification
```caddyfile Run `python -m unittest discover -s server/audiobook -p 'test_*.py' -v`.
argus.kusoft.xyz { `smoke_live.py`, run on CMP, performs real EPUB upload, VoiceStudio synthesis and
encode zstd gzip public M4A download with a disposable device, then revokes it. Set
handle_path /aletheia-tts/* { `AUDIOBOOK_SMOKE_FORMAT=fb2` for the same test with FB2. Unit tests mock synthesis.
reverse_proxy 192.168.0.112:8765
}
handle {
reverse_proxy 127.0.0.1:5105
}
}
```
Validate with `caddy validate` before reloading Caddy. `/health` is public and reports `modelLoaded`; all `/v1/audiobooks` endpoints require the bearer token.
## API
- `POST /v1/audiobooks` — multipart fields `book`, `title`, `author`
- `GET /v1/audiobooks/{id}` — status, stage, processed/total characters, chapter, duration and chapter markers
- `GET /v1/audiobooks/{id}/file` — completed M4A
- `DELETE /v1/audiobooks/{id}` — cancel an active job or remove a completed job
Interrupted receiver processes requeue unfinished jobs on the next start. Existing WAV chunks are reused, so synthesis resumes at the first missing chunk.
@@ -0,0 +1,14 @@
[Unit]
Description=Aletheia delivered audiobook cleanup
After=aletheia-audiobook-cmp.service
[Service]
Type=simple
WorkingDirectory=/home/sevenhill/apps/aletheia-audiobook/service
Environment=AUDIOBOOK_SERVICE_ROOT=/home/sevenhill/apps/aletheia-audiobook
ExecStart=/home/sevenhill/apps/VoiceStudio/.venv/bin/python audio_cleanup.py
Restart=always
RestartSec=5
[Install]
WantedBy=default.target
@@ -7,12 +7,8 @@ Wants=network-online.target
Type=simple Type=simple
WorkingDirectory=/home/sevenhill/apps/aletheia-audiobook/service WorkingDirectory=/home/sevenhill/apps/aletheia-audiobook/service
Environment=AUDIOBOOK_SERVICE_ROOT=/home/sevenhill/apps/aletheia-audiobook Environment=AUDIOBOOK_SERVICE_ROOT=/home/sevenhill/apps/aletheia-audiobook
Environment=HF_HOME=/home/sevenhill/apps/qwen3-audiobook-test/hf Environment=VOICESTUDIO_VOICE=f24f004e
Environment=QWEN_TTS_MODEL_ID=Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice ExecStart=/home/sevenhill/apps/VoiceStudio/.venv/bin/python -m uvicorn audiobook_service:app --host 0.0.0.0 --port 8765 --workers 1
Environment=QWEN_TTS_SPEAKER=Ryan
Environment=QWEN_TTS_GPU_IDS=0,1,2
Environment=CPATH=/home/sevenhill/apps/qwen3-audiobook-test/sysdeps/extracted/usr/include/python3.12:/home/sevenhill/apps/qwen3-audiobook-test/sysdeps/extracted/usr/include
ExecStart=/home/sevenhill/apps/qwen3-tts-venv/bin/python -m uvicorn audiobook_service:app --host 0.0.0.0 --port 8765 --workers 1
Restart=always Restart=always
RestartSec=5 RestartSec=5
+161
View File
@@ -0,0 +1,161 @@
"""Durable post-delivery cleanup. A ready-job DELETE is the phone's acknowledgement.
Runs separately so it can be deployed without interrupting an active renderer.
Never deletes voice profiles, models, or another book's finished output.
"""
import hashlib
import json
import logging
import os
from pathlib import Path
import re
import shutil
import sqlite3
import time
ROOT = Path(os.environ.get('AUDIOBOOK_SERVICE_ROOT', '/home/sevenhill/apps/aletheia-audiobook'))
OUTPUTS = Path(os.environ.get('VOICESTUDIO_OUTPUTS', '/home/sevenhill/voicestudio-data/outputs'))
DATABASE = ROOT / 'audiobook-jobs/jobs.db3'
VOICE_DATABASE = OUTPUTS.parent / 'omnivoice.db'
LOG = logging.getLogger(__name__)
def connect():
connection = sqlite3.connect(DATABASE, timeout=30)
connection.row_factory = sqlite3.Row
return connection
def initialize():
with connect() as db:
db.executescript('''
CREATE TABLE IF NOT EXISTS delivered_audio_cleanup (
id TEXT PRIMARY KEY, output_path TEXT NOT NULL,
acknowledged INTEGER NOT NULL DEFAULT 0, cutoff REAL,
artifact TEXT, completed INTEGER NOT NULL DEFAULT 0
);
CREATE TRIGGER IF NOT EXISTS acknowledge_delivered_audio
BEFORE DELETE ON jobs WHEN OLD.status = 'ready'
BEGIN
INSERT INTO delivered_audio_cleanup(id, output_path, acknowledged, cutoff)
VALUES(OLD.id, OLD.output_path, 1, unixepoch())
ON CONFLICT(id) DO UPDATE SET acknowledged=1, cutoff=unixepoch();
END;
''')
def remember_output(destination, source):
"""Persist outside the job directory, before the result becomes downloadable."""
source = Path(source).resolve()
if source.parent != OUTPUTS.resolve() or not re.fullmatch(r'audiobook_[a-zA-Z0-9_-]+\.m4b', source.name):
raise ValueError('Unexpected VoiceStudio output')
with connect() as db:
db.execute('''INSERT INTO delivered_audio_cleanup(id, output_path, artifact)
VALUES(?, ?, ?) ON CONFLICT(id) DO UPDATE SET artifact=excluded.artifact''',
(Path(destination).parent.name, str(destination), source.name))
def digest(path):
with path.open('rb') as stream:
return hashlib.file_digest(stream, 'sha256').digest()
def capture_legacy_outputs():
"""Also covers a worker that was already running when cleanup was installed."""
with connect() as db:
rows = db.execute('''SELECT j.id, j.output_path FROM jobs j
LEFT JOIN delivered_audio_cleanup c ON c.id=j.id
WHERE j.status IN ('encoding','ready') AND c.artifact IS NULL''').fetchall()
for row in rows:
local = Path(row['output_path'])
if not local.is_file() or not local.stat().st_size:
continue
candidates = [p for p in OUTPUTS.glob('audiobook_*.m4b')
if p.stat().st_size == local.stat().st_size]
if not candidates:
continue
expected = digest(local)
matches = [p for p in candidates if digest(p) == expected]
if len(matches) == 1:
remember_output(local, matches[0])
def renderers_idle():
with connect() as db:
if db.execute("SELECT 1 FROM jobs WHERE status IN ('queued','extracting','synthesizing','encoding') LIMIT 1").fetchone():
return False
# VoiceStudio's own jobs may belong to a different client.
if not VOICE_DATABASE.is_file():
return False
with sqlite3.connect(f'file:{VOICE_DATABASE}?mode=ro', uri=True) as db:
return not db.execute("SELECT 1 FROM jobs WHERE status IN ('queued','running','pending') LIMIT 1").fetchone()
def remove_artifact(name):
if not re.fullmatch(r'audiobook_[a-zA-Z0-9_-]+\.m4b', name):
raise ValueError('Unsafe artifact name')
root = OUTPUTS.resolve()
output = (root / name).resolve()
work = (root / Path(name).stem).resolve()
if output.parent != root or work.parent != root:
raise ValueError('Artifact escapes output directory')
cache = (root / 'longform_cache').resolve()
concat = work / 'concat.txt'
removed = 0
if concat.is_file():
for line in concat.read_text().splitlines():
match = re.fullmatch(r"file '([^']+)'", line)
if not match:
raise ValueError('Unexpected concat entry')
wav = Path(match[1]).resolve()
if wav.parent != cache or not re.fullmatch(r'[a-f0-9]{20}\.wav', wav.name):
raise ValueError('Unsafe cache path')
if wav.is_file():
removed += wav.stat().st_size
wav.unlink()
if output.is_file():
removed += output.stat().st_size
output.unlink()
if work.exists():
shutil.rmtree(work)
return removed
def sweep():
capture_legacy_outputs()
with connect() as db:
# Serialize against receiver job creation/state changes. A new render
# cannot begin between the idle check and the last unlink.
db.execute('BEGIN IMMEDIATE')
pending = db.execute('''SELECT * FROM delivered_audio_cleanup
WHERE acknowledged=1 AND completed=0 AND artifact IS NOT NULL''').fetchall()
if not pending or not VOICE_DATABASE.is_file():
return
with sqlite3.connect(VOICE_DATABASE, timeout=30) as voice_db:
voice_db.execute('BEGIN IMMEDIATE')
if not renderers_idle():
return
for row in pending:
removed = remove_artifact(row['artifact'])
# Segment WAVs are a disposable shared cache. Only prune old
# entries while idle; newly created files are never candidates.
segment_root = OUTPUTS / 'longform_cache/segments'
for path in segment_root.glob('*.wav'):
if path.is_symlink() or not re.fullmatch(r'[a-f0-9]{20}\.wav', path.name):
continue
if path.stat().st_mtime < row['cutoff']:
removed += path.stat().st_size
path.unlink()
db.execute('UPDATE delivered_audio_cleanup SET completed=1 WHERE id=?', (row['id'],))
LOG.info('Delivered audiobook %s cleaned: %d bytes freed', row['id'], removed)
if __name__ == '__main__':
logging.basicConfig(level=logging.INFO)
initialize()
while True:
try:
sweep()
except Exception:
LOG.exception('Cleanup deferred; durable acknowledgement retained')
time.sleep(5)
+112 -334
View File
@@ -1,13 +1,11 @@
from __future__ import annotations from __future__ import annotations
import gc import sys
import hashlib from device_auth import DeviceStore
import hmac
import html import html
import json import json
import os import os
import re import re
import secrets
import shutil import shutil
import sqlite3 import sqlite3
import subprocess import subprocess
@@ -15,68 +13,24 @@ import threading
import time import time
import uuid import uuid
import zipfile import zipfile
from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager from contextlib import contextmanager
from pathlib import Path, PurePosixPath from pathlib import Path, PurePosixPath
from typing import TYPE_CHECKING, Annotated, Any, Iterator from typing import TYPE_CHECKING, Annotated, Any, Iterator
from xml.etree import ElementTree from xml.etree import ElementTree
import imageio_ffmpeg
import soundfile as sf
from fastapi import Depends, FastAPI, File, Form, Header, HTTPException, UploadFile from fastapi import Depends, FastAPI, File, Form, Header, HTTPException, UploadFile
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from num2words import num2words from num2words import num2words
if TYPE_CHECKING:
from qwen_tts import Qwen3TTSModel
ROOT = Path(os.environ.get("AUDIOBOOK_SERVICE_ROOT", "/home/sevenhill/apps/aletheia-audiobook")) ROOT = Path(os.environ.get("AUDIOBOOK_SERVICE_ROOT", "/home/sevenhill/apps/aletheia-audiobook"))
SERVICE_ROOT = ROOT / "service" SERVICE_ROOT = ROOT / "service"
DATA_ROOT = ROOT / "audiobook-jobs" DATA_ROOT = ROOT / "audiobook-jobs"
TOKEN_FILE = SERVICE_ROOT / "service-token.txt"
DATABASE = DATA_ROOT / "jobs.db3" DATABASE = DATA_ROOT / "jobs.db3"
PERFORMANCE_LOG = DATA_ROOT / "performance.jsonl"
MODEL_ID = os.environ.get("QWEN_TTS_MODEL_ID", "Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice")
MAX_UPLOAD_BYTES = 200 * 1024 * 1024 MAX_UPLOAD_BYTES = 200 * 1024 * 1024
GPU_IDS = tuple(
int(value.strip())
for value in os.environ.get("QWEN_TTS_GPU_IDS", "0,1,2").split(",")
if value.strip()
)
SPEAKER = os.environ.get("QWEN_TTS_SPEAKER", "Ryan")
VOICE_INSTRUCTION = (
"Read in a calm, clear, natural audiobook style with steady pacing and distinct diction."
)
SERVICE_ROOT.mkdir(parents=True, exist_ok=True) SERVICE_ROOT.mkdir(parents=True, exist_ok=True)
DATA_ROOT.mkdir(parents=True, exist_ok=True) DATA_ROOT.mkdir(parents=True, exist_ok=True)
OLLAMA_PAUSE_MARKER = SERVICE_ROOT / "ollama-paused-for-audiobook"
if not TOKEN_FILE.exists():
TOKEN_FILE.write_text(secrets.token_urlsafe(48), encoding="ascii")
API_TOKEN = TOKEN_FILE.read_text(encoding="ascii").strip()
app = FastAPI(title="Aletheia CMP Audiobook Service", version="2.0.0")
def pause_ollama_for_audiobook() -> None:
"""Release every CMP GPU before loading the three Qwen workers."""
subprocess.run(["systemctl", "--user", "stop", "ollama.service"], check=True)
subprocess.run(["systemctl", "--user", "mask", "--runtime", "ollama.service"], check=True)
OLLAMA_PAUSE_MARKER.write_text("1", encoding="ascii")
def restore_ollama_after_audiobook() -> None:
if not OLLAMA_PAUSE_MARKER.exists():
return
subprocess.run(["systemctl", "--user", "unmask", "--runtime", "ollama.service"], check=True)
subprocess.run(["systemctl", "--user", "start", "ollama.service"], check=True)
OLLAMA_PAUSE_MARKER.unlink(missing_ok=True)
# The worker terminates after every job to release CUDA/Triton contexts. The
# next lightweight receiver process restores Ollama before accepting new work.
restore_ollama_after_audiobook()
app = FastAPI(title="Aletheia CMP Audiobook Service", version="1.1.0")
queue_condition = threading.Condition() queue_condition = threading.Condition()
queued_jobs: list[str] = [] queued_jobs: list[str] = []
worker_state = {"activeJobId": None, "modelLoaded": False} worker_state = {"activeJobId": None, "modelLoaded": False}
@@ -118,6 +72,9 @@ def initialize_database() -> None:
) )
""" """
) )
if "owner_id" not in {row[1] for row in connection.execute("PRAGMA table_info(jobs)")}:
connection.execute("ALTER TABLE jobs ADD COLUMN owner_id TEXT")
connection.execute("CREATE INDEX IF NOT EXISTS idx_jobs_owner ON jobs(owner_id)")
connection.execute( connection.execute(
"UPDATE jobs SET status='queued', stage='queued' " "UPDATE jobs SET status='queued', stage='queued' "
"WHERE status IN ('extracting','synthesizing','encoding')" "WHERE status IN ('extracting','synthesizing','encoding')"
@@ -128,10 +85,31 @@ def initialize_database() -> None:
queued_jobs.extend(row["id"] for row in rows) queued_jobs.extend(row["id"] for row in rows)
def require_token(authorization: Annotated[str | None, Header()] = None) -> None: devices = DeviceStore(DATABASE)
expected = f"Bearer {API_TOKEN}"
if not authorization or not hmac.compare_digest(authorization, expected):
raise HTTPException(status_code=401, detail="Invalid API token") def require_token(authorization: Annotated[str | None, Header()] = None) -> str:
try:
if not authorization or not authorization.startswith("Bearer "):
raise PermissionError()
return devices.authenticate(authorization[7:])
except PermissionError:
raise HTTPException(status_code=401, detail="Invalid device credential")
@app.post("/v1/devices/enroll")
def enroll_device(payload: dict) -> dict:
try:
return devices.enroll(payload.get("code", ""), payload.get("label", "Android"))
except PermissionError:
raise HTTPException(status_code=403, detail="Invalid or expired pairing code")
def owned_job(job_id: str, owner: str) -> sqlite3.Row:
row = get_job(job_id)
if row["owner_id"] != owner:
raise HTTPException(status_code=404, detail="Audiobook job not found")
return row
def now_ms() -> int: def now_ms() -> int:
@@ -170,17 +148,22 @@ def job_payload(row: sqlite3.Row) -> dict:
@app.get("/health") @app.get("/health")
def health() -> dict: def health() -> dict:
from voicestudio_client import check_available
try:
check_available()
except Exception:
raise HTTPException(status_code=503, detail="VoiceStudio unavailable")
return { return {
"status": "ok", "status": "ok",
"workerMode": "lazy-model", "workerMode": "voicestudio",
"activeJobId": worker_state["activeJobId"],
"modelLoaded": worker_state["modelLoaded"], "modelLoaded": worker_state["modelLoaded"],
} }
@app.post("/v1/audiobooks", dependencies=[Depends(require_token)]) @app.post("/v1/audiobooks")
async def create_audiobook( async def create_audiobook(
book: Annotated[UploadFile, File()], book: Annotated[UploadFile, File()],
owner: Annotated[str, Depends(require_token)],
title: Annotated[str, Form()], title: Annotated[str, Form()],
author: Annotated[str, Form()] = "", author: Annotated[str, Form()] = "",
) -> dict: ) -> dict:
@@ -207,8 +190,8 @@ async def create_audiobook(
connection.execute( connection.execute(
""" """
INSERT INTO jobs ( INSERT INTO jobs (
id,title,author,source_name,input_path,output_path,status,stage,created_at,updated_at id,title,author,source_name,input_path,output_path,status,stage,created_at,updated_at,owner_id
) VALUES (?,?,?,?,?,?,?,?,?,?) ) VALUES (?,?,?,?,?,?,?,?,?,?,?)
""", """,
( (
job_id, job_id,
@@ -221,6 +204,7 @@ async def create_audiobook(
"queued", "queued",
timestamp, timestamp,
timestamp, timestamp,
owner,
), ),
) )
with queue_condition: with queue_condition:
@@ -229,14 +213,14 @@ async def create_audiobook(
return job_payload(get_job(job_id)) return job_payload(get_job(job_id))
@app.get("/v1/audiobooks/{job_id}", dependencies=[Depends(require_token)]) @app.get("/v1/audiobooks/{job_id}")
def audiobook_status(job_id: str) -> dict: def audiobook_status(job_id: str, owner: Annotated[str, Depends(require_token)]) -> dict:
return job_payload(get_job(job_id)) return job_payload(owned_job(job_id, owner))
@app.get("/v1/audiobooks/{job_id}/file", dependencies=[Depends(require_token)]) @app.get("/v1/audiobooks/{job_id}/file")
def audiobook_file(job_id: str) -> FileResponse: def audiobook_file(job_id: str, owner: Annotated[str, Depends(require_token)]) -> FileResponse:
row = get_job(job_id) row = owned_job(job_id, owner)
output = Path(row["output_path"]) output = Path(row["output_path"])
if row["status"] != "ready" or not output.is_file(): if row["status"] != "ready" or not output.is_file():
raise HTTPException(status_code=409, detail="Audiobook is not ready") raise HTTPException(status_code=409, detail="Audiobook is not ready")
@@ -244,9 +228,9 @@ def audiobook_file(job_id: str) -> FileResponse:
return FileResponse(output, media_type="audio/mp4", filename=f"{safe_title}.m4a") return FileResponse(output, media_type="audio/mp4", filename=f"{safe_title}.m4a")
@app.delete("/v1/audiobooks/{job_id}", dependencies=[Depends(require_token)]) @app.delete("/v1/audiobooks/{job_id}")
def cancel_or_delete(job_id: str) -> dict: def cancel_or_delete(job_id: str, owner: Annotated[str, Depends(require_token)]) -> dict:
row = get_job(job_id) row = owned_job(job_id, owner)
if row["status"] == "queued": if row["status"] == "queued":
with queue_condition: with queue_condition:
queued_jobs[:] = [queued_id for queued_id in queued_jobs if queued_id != job_id] queued_jobs[:] = [queued_id for queued_id in queued_jobs if queued_id != job_id]
@@ -394,273 +378,61 @@ def cancellation_requested(job_id: str) -> bool:
return row is None or bool(row["cancel_requested"]) return row is None or bool(row["cancel_requested"])
def load_model(device_id: int) -> "Qwen3TTSModel":
import torch
from qwen_tts import Qwen3TTSModel
if not torch.cuda.is_available():
raise RuntimeError("CUDA is unavailable")
torch.set_float32_matmul_precision("high")
model = Qwen3TTSModel.from_pretrained(
MODEL_ID,
device_map=f"cuda:{device_id}",
dtype=torch.bfloat16,
attn_implementation="sdpa",
)
return model
def load_models() -> list["Qwen3TTSModel"]:
if not GPU_IDS:
raise RuntimeError("No Qwen GPU IDs are configured")
models = [load_model(device_id) for device_id in GPU_IDS]
worker_state["modelLoaded"] = True
return models
def generate_custom_voice_with_metrics(
model: Any,
batch_chunks: list[dict],
device_id: int,
) -> tuple[list[Any], int]:
"""Measure Qwen talker and codec stages without changing generated audio."""
import torch
timings: dict[str, float] = {}
original_generate = model.model.generate
speech_tokenizer = model.model.speech_tokenizer
original_decode = speech_tokenizer.decode
def synchronize() -> None:
if torch.cuda.is_available():
torch.cuda.synchronize(device_id)
def timed_generate(*args: Any, **kwargs: Any) -> Any:
synchronize()
started = time.perf_counter()
try:
return original_generate(*args, **kwargs)
finally:
synchronize()
timings["talkerGenerateSeconds"] = time.perf_counter() - started
def timed_decode(*args: Any, **kwargs: Any) -> Any:
synchronize()
started = time.perf_counter()
try:
return original_decode(*args, **kwargs)
finally:
synchronize()
timings["codecDecodeSeconds"] = time.perf_counter() - started
model.model.generate = timed_generate
speech_tokenizer.decode = timed_decode
started = time.perf_counter()
try:
wavs, sample_rate = model.generate_custom_voice(
text=[chunk["text"] for chunk in batch_chunks],
language=["Russian"] * len(batch_chunks),
speaker=[SPEAKER] * len(batch_chunks),
instruct=[VOICE_INSTRUCTION] * len(batch_chunks),
)
synchronize()
finally:
model.model.generate = original_generate
speech_tokenizer.decode = original_decode
total_seconds = time.perf_counter() - started
talker_seconds = timings.get("talkerGenerateSeconds", 0.0)
codec_seconds = timings.get("codecDecodeSeconds", 0.0)
record = {
"timestampMs": now_ms(),
"batchSize": len(batch_chunks),
"characters": sum(len(chunk["text"]) for chunk in batch_chunks),
"totalSeconds": round(total_seconds, 4),
"talkerGenerateSeconds": round(talker_seconds, 4),
"codecDecodeSeconds": round(codec_seconds, 4),
"wrapperSeconds": round(max(0.0, total_seconds - talker_seconds - codec_seconds), 4),
"cudaPeakAllocatedMiB": round(
torch.cuda.max_memory_allocated(device_id) / (1024 * 1024), 1
),
}
with PERFORMANCE_LOG.open("a", encoding="utf-8") as output:
output.write(json.dumps(record, ensure_ascii=False) + "\n")
return wavs, sample_rate
def unload_models(models: list[Any]) -> None:
if models:
models.clear()
import torch
gc.collect()
if torch.cuda.is_available():
for device_id in GPU_IDS:
torch.cuda.empty_cache()
else:
gc.collect()
worker_state["modelLoaded"] = False
def encode_m4a(chunk_files: list[Path], output: Path, job_root: Path) -> None:
concat = job_root / "concat.txt"
concat.write_text(
"\n".join(f"file '{path.as_posix()}'" for path in chunk_files),
encoding="utf-8",
)
command = [
imageio_ffmpeg.get_ffmpeg_exe(),
"-y",
"-f",
"concat",
"-safe",
"0",
"-i",
str(concat),
"-vn",
"-c:a",
"aac",
"-b:a",
"96k",
"-movflags",
"+faststart",
str(output),
]
completed = subprocess.run(command, capture_output=True, text=True, encoding="utf-8", errors="replace")
if completed.returncode != 0:
raise RuntimeError(completed.stderr[-2000:] or "FFmpeg failed")
def run_job(job_id: str) -> None: def run_job(job_id: str) -> None:
from voicestudio_client import render_audiobook, split_sections
row = get_job(job_id) row = get_job(job_id)
job_root = Path(row["input_path"]).parent job_root = Path(row["input_path"]).parent
chunks_root = job_root / "chunks"
chunks_root.mkdir(exist_ok=True)
models: list[Any] = []
try: try:
if cancellation_requested(job_id): if cancellation_requested(job_id):
finish_cancellation(job_id, job_root) finish_cancellation(job_id, job_root)
return return
update_job(job_id, status="extracting", stage="extracting", error_message=None) update_job(job_id, status="extracting", stage="extracting", error_message=None)
input_path = Path(row["input_path"]) source = Path(row["input_path"])
sections = extract_epub(input_path) if input_path.suffix.lower() == ".epub" else extract_fb2(input_path) sections = extract_epub(source) if source.suffix.lower() == ".epub" else extract_fb2(source)
chunks = split_chunks(sections) sections = split_sections(sections)
if not chunks: if not sections:
raise RuntimeError("В книге не найден русский текст для озвучивания") raise RuntimeError("В книге не найден текст для озвучивания")
total = sum(len(chunk["text"]) for chunk in chunks) total = sum(len(text) for _, text in sections)
manifest_path = job_root / "chunks.json" update_job(job_id, status="synthesizing", stage="synthesizing", total_characters=total,
manifest_path.write_text(json.dumps(chunks, ensure_ascii=False, indent=2), encoding="utf-8") processed_characters=0, duration_ms=0, chapters_json="[]",
update_job(job_id, status="synthesizing", stage="loading_model", total_characters=total) current_chapter="Проверка сохранённых фрагментов и озвучивание недостающих…")
completed = {}
verified = set()
def on_event(event):
if event.get("type") == "chapter":
index = int(event["index"])
completed[index] = event
duration = 0
markers = []
for _, chapter in sorted(completed.items()):
markers.append({"title": chapter.get("title", "Раздел"), "startMs": duration})
duration += round(float(chapter.get("duration_s", 0)) * 1000)
processed = sum(len(sections[i][1]) for i in completed if 0 <= i < len(sections))
label = event.get("title")
if event.get("stage") != "gpu_render":
verified.add(index)
label = f"Проверка готовых фрагментов: {len(verified)} из {len(sections)}"
update_job(job_id, processed_characters=processed,
current_chapter=label, duration_ms=duration,
chapters_json=json.dumps(markers, ensure_ascii=False))
elif event.get("type") == "started":
update_job(job_id, current_chapter="Проверка готовых фрагментов перед сборкой M4A…")
elif event.get("type") == "assembling":
update_job(job_id, status="encoding", stage="encoding", current_chapter=None)
result = render_audiobook(sections, row["title"], row["author"], Path(row["output_path"]),
on_event, lambda: cancellation_requested(job_id))
if cancellation_requested(job_id): if cancellation_requested(job_id):
finish_cancellation(job_id, job_root) finish_cancellation(job_id, job_root)
return return
pause_ollama_for_audiobook() update_job(job_id, status="ready", stage="ready", processed_characters=total,
models = load_models() duration_ms=round(float(result["duration_s"]) * 1000), current_chapter=None)
import torch
processed = 0
duration_ms = 0
chapter_markers: list[dict] = []
last_chapter: str | None = None
chunk_files: list[Path] = []
index = 0
while index < len(chunks):
if cancellation_requested(job_id):
finish_cancellation(job_id, job_root)
return
output = chunks_root / f"{index:06d}.wav"
completed_indices: list[int]
if output.is_file():
completed_indices = [index]
else:
batch_indices: list[int] = []
cursor = index
while cursor < len(chunks) and len(batch_indices) < len(models):
candidate = chunks_root / f"{cursor:06d}.wav"
if candidate.is_file():
break
batch_indices.append(cursor)
cursor += 1
with ThreadPoolExecutor(max_workers=len(batch_indices)) as executor:
futures = [
executor.submit(
generate_custom_voice_with_metrics,
model,
[chunks[batch_index]],
device_id,
)
for batch_index, model, device_id in zip(
batch_indices,
models[: len(batch_indices)],
GPU_IDS[: len(batch_indices)],
strict=True,
)
]
rendered = [future.result() for future in futures]
if cancellation_requested(job_id):
finish_cancellation(job_id, job_root)
return
for batch_index, (wavs, sample_rate) in zip(batch_indices, rendered, strict=True):
sf.write(
chunks_root / f"{batch_index:06d}.wav",
wavs[0],
sample_rate,
subtype="PCM_16",
)
completed_indices = batch_indices
for completed_index in completed_indices:
completed_chunk = chunks[completed_index]
completed_output = chunks_root / f"{completed_index:06d}.wav"
if completed_chunk["chapter"] != last_chapter:
chapter_markers.append({"title": completed_chunk["chapter"], "startMs": duration_ms})
last_chapter = completed_chunk["chapter"]
info = sf.info(completed_output)
duration_ms += round(info.frames * 1000 / info.samplerate)
chunk_files.append(completed_output)
processed += len(completed_chunk["text"])
update_job(
job_id,
status="synthesizing",
stage="synthesizing",
processed_characters=processed,
current_chapter=completed_chunk["chapter"],
duration_ms=duration_ms,
chapters_json=json.dumps(chapter_markers, ensure_ascii=False),
)
index = completed_indices[-1] + 1
unload_models(models)
models = []
if cancellation_requested(job_id):
finish_cancellation(job_id, job_root)
return
update_job(job_id, status="encoding", stage="encoding", current_chapter=None)
output = Path(row["output_path"])
encode_m4a(chunk_files, output, job_root)
if cancellation_requested(job_id):
finish_cancellation(job_id, job_root)
return
if not output.is_file() or output.stat().st_size == 0:
raise RuntimeError("Итоговый M4A-файл не создан")
update_job(
job_id,
status="ready",
stage="ready",
processed_characters=total,
current_chapter=None,
chapters_json=json.dumps(chapter_markers, ensure_ascii=False),
)
except Exception as error: except Exception as error:
update_job( if cancellation_requested(job_id):
job_id, finish_cancellation(job_id, job_root)
status="failed", else:
stage="failed", update_job(job_id, status="failed", stage="failed", error_message=f"{type(error).__name__}: {error}")
error_message=f"{type(error).__name__}: {error}",
)
finally:
unload_models(models)
def worker_loop() -> None: def worker_loop() -> None:
while True: while True:
@@ -670,19 +442,25 @@ def worker_loop() -> None:
job_id = queued_jobs.pop(0) job_id = queued_jobs.pop(0)
worker_state["activeJobId"] = job_id worker_state["activeJobId"] = job_id
try: try:
try: env = dict(os.environ, AUDIOBOOK_WORKER_PROCESS="1")
run_job(job_id) process = subprocess.Popen([sys.executable, str(Path(__file__).with_name("worker_runner.py")), job_id], env=env)
except HTTPException as error: while process.poll() is None:
if error.status_code != 404: try:
raise worker_state["modelLoaded"] = get_job(job_id)["status"] == "synthesizing"
except HTTPException:
pass
time.sleep(0.5)
if process.returncode:
try:
update_job(job_id, status="failed", stage="failed", error_message="Audiobook worker stopped unexpectedly")
except HTTPException:
pass
finally: finally:
worker_state["activeJobId"] = None worker_state["activeJobId"] = None
# Qwen/Triton can retain a CUDA context after Python references are worker_state["modelLoaded"] = False
# released. Let systemd start a clean idle receiver so CMP VRAM is
# fully available between audiobook requests. Unfinished jobs are
# restored from SQLite by initialize_database() after restart.
os._exit(0)
initialize_database() if os.environ.get("AUDIOBOOK_WORKER_PROCESS") != "1":
threading.Thread(target=worker_loop, name="audiobook-worker", daemon=True).start() initialize_database()
if os.environ.get("AUDIOBOOK_DISABLE_WORKER") != "1":
threading.Thread(target=worker_loop, name="audiobook-worker", daemon=True).start()
+62
View File
@@ -0,0 +1,62 @@
"""CMP opt-in: one continuous chapter worker per GPU, with immediate progress."""
import asyncio
import json
import logging
import httpx
logger = logging.getLogger(__name__)
_lock = asyncio.Lock()
async def warm_chapters(script, count, endpoints, params, disconnected=None):
if count <= 0:
return
if not endpoints:
raise ValueError('No CMP GPU endpoints configured')
async with _lock:
if disconnected and await disconnected():
raise asyncio.CancelledError()
async with httpx.AsyncClient(timeout=httpx.Timeout(7200, connect=15), trust_env=False) as client:
indices = iter(range(count))
results = asyncio.Queue()
async def worker(endpoint):
try:
for index in indices:
payload = dict(params, text=script, chapter_index=index)
response = await client.post(endpoint + '/audiobook/preview', json=payload)
response.raise_for_status()
result = response.json()
logger.info('CMP_GPU_CHAPTER gpu=%s chapter=%s cached=%s duration=%s',
endpoint, index, result.get('cached'), result.get('duration_s'))
event = dict(type='chapter', index=index, total=count,
title=result.get('title', ''), duration_s=result['duration_s'],
cached=result.get('cached', False), stage='gpu_render')
await results.put(('chapter', event))
except Exception as error:
await results.put(('error', error))
finally:
await results.put(('finished', None))
tasks = [asyncio.create_task(worker(endpoint)) for endpoint in endpoints[:count]]
finished = 0
try:
while finished < len(tasks):
if disconnected and await disconnected():
raise asyncio.CancelledError()
try:
kind, value = await asyncio.wait_for(results.get(), timeout=5)
except asyncio.TimeoutError:
yield ': cmp GPU rendering\n\n'
continue
if kind == 'error':
raise value
if kind == 'finished':
finished += 1
else:
yield 'data: ' + json.dumps(value) + '\n\n'
finally:
for task in tasks:
if not task.done():
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
@@ -0,0 +1,35 @@
"""Apply the duration-aware export fix to the installed VoiceStudio router."""
from pathlib import Path
import sys
path = Path(sys.argv[1])
source = path.read_text(encoding='utf-8')
old = ''' await run_ffmpeg(
build_render_cmd('''
new = ''' # Long audiobooks can take hours to encode on the CMP CPU.
# Keep a bounded timeout proportional to the prepared audio duration.
encode_timeout = min(21600.0, max(1800.0,
sum(d for _, d in chapters_meta) / 1000.0 * 0.5))
returncode, _, encode_error = await run_ffmpeg(
build_render_cmd('''
tail = ''' job_id=job_id,
)
if job_store is not None:'''
replacement = ''' job_id=job_id, timeout=encode_timeout,
)
if returncode != 0:
logger.error("[%s] ffmpeg export failed: %s", job_id,
(encode_error or b"").decode(errors="replace")[-2000:])
raise RuntimeError("Audio export failed")
if job_store is not None:'''
if 'encode_timeout = min(21600.0' not in source:
assert source.count(old) == 1
assert source.count(tail) == 1
backup = path.with_suffix('.py.before-duration-timeout')
if not backup.exists():
backup.write_text(source, encoding='utf-8')
source = source.replace(old, new).replace(tail, replacement)
compile(source, str(path), 'exec')
path.write_text(source, encoding='utf-8')
@@ -0,0 +1,24 @@
"""Idempotent VoiceStudio patch: CMP's warm pass already owns the active cache."""
from pathlib import Path
import sys
def patch_router(path):
path = Path(path)
source = path.read_text()
old = ' prune_cache_dir(cache_dir) # bound disk before this job adds its chapters'
new = ''' # CMP has already rendered the chapters on all GPUs. Pruning here
# deletes this job's inputs and silently repeats synthesis on GPU 0.
# Aletheia removes delivered audio through its acknowledgement cleaner.
if not os.environ.get("CMP_AUDIOBOOK_GPUS"):
prune_cache_dir(cache_dir) # bound disk before this job adds its chapters'''
if new in source:
return
if source.count(old) != 1:
raise RuntimeError('Unexpected VoiceStudio router; patch requires review')
path.with_suffix('.py.before-cmp-cache-fix').write_text(source)
path.write_text(source.replace(old, new))
if __name__ == '__main__':
patch_router(sys.argv[1])
@@ -0,0 +1,82 @@
import asyncio
import json
import sys
import unittest
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'backend'))
from services.cmp_multigpu import warm_chapters
class DynamicQueueTests(unittest.IsolatedAsyncioTestCase):
async def test_free_gpu_continues_and_reports_before_slow_gpu_finishes(self):
slow = asyncio.Event()
dispatched = []
active = set()
class Response:
def raise_for_status(self): pass
def json(self): return {'duration_s': 1, 'cached': False}
class Client:
def __init__(self, **kwargs): pass
async def __aenter__(self): return self
async def __aexit__(self, *args): pass
async def post(self, url, json):
self_outer.assertNotIn(url, active)
active.add(url)
index = json['chapter_index']
dispatched.append((url, index))
try:
if index == 0:
await slow.wait()
else:
await asyncio.sleep(.001)
return Response()
finally:
active.remove(url)
self_outer = self
with patch('services.cmp_multigpu.httpx.AsyncClient', Client):
stream = warm_chapters('text', 6, ['gpu0', 'gpu1', 'gpu2'], {})
try:
first = await asyncio.wait_for(anext(stream), .3)
self.assertTrue(first.startswith('data:'))
self.assertFalse(slow.is_set())
while len(dispatched) < 6:
await asyncio.wait_for(anext(stream), .3)
self.assertTrue(any(index >= 3 and url != 'gpu0/audiobook/preview' for url, index in dispatched))
slow.set()
remaining = [event async for event in stream]
self.assertTrue(any('"index": 0' in event for event in remaining))
self.assertEqual(list(range(6)), sorted(index for _, index in dispatched))
finally:
slow.set()
await stream.aclose()
self.assertFalse(active)
async def test_closing_stream_cancels_inflight_requests(self):
stopped = asyncio.Event()
class Response:
def raise_for_status(self): pass
def json(self): return {'duration_s': 1}
class Client:
def __init__(self, **kwargs): pass
async def __aenter__(self): return self
async def __aexit__(self, *args): pass
async def post(self, url, json):
if json['chapter_index'] == 0:
try: await asyncio.Event().wait()
finally: stopped.set()
return Response()
with patch('services.cmp_multigpu.httpx.AsyncClient', Client):
stream = warm_chapters('text', 2, ['gpu0', 'gpu1'], {})
try:
await asyncio.wait_for(anext(stream), .3)
finally:
await stream.aclose()
self.assertTrue(stopped.is_set())
if __name__ == '__main__': unittest.main()
@@ -0,0 +1,49 @@
"""Execute VoiceStudio's real assembly generator against a cache over its cap."""
import os
from pathlib import Path
import tempfile
import types
import unittest
from unittest.mock import AsyncMock, patch
from api.routers import audiobook as router
from services.audiobook import parse_audiobook_script
from services.longform_render import prune_cache_dir
class PreparedCacheTests(unittest.IsolatedAsyncioTestCase):
async def test_cmp_assembly_keeps_warmed_audio_even_over_cache_limit(self):
await self.check_assembly(0)
async def test_failed_encoder_does_not_report_done(self):
await self.check_assembly(1)
async def check_assembly(self, returncode):
with tempfile.TemporaryDirectory() as root:
wav = Path(root) / 'longform_cache/fixture.wav'
wav.parent.mkdir()
wav.write_bytes(b'prepared chapter larger than configured cap')
async def cached_chapter(*args, **kwargs):
self.assertTrue(wav.is_file(), 'assembly evicted an already rendered chapter')
return str(wav), 79213.0, True, None
job_store = types.SimpleNamespace(**{n: lambda *a, **k: None for n in
['create', 'mark_running', 'append_event', 'mark_done', 'mark_failed']})
with patch.dict(os.environ, {'CMP_AUDIOBOOK_GPUS': 'gpu0,gpu1,gpu2'}), \
patch('core.config.OUTPUTS_DIR', root), \
patch('core.job_store', job_store, create=True), \
patch('services.ffmpeg_utils.find_ffmpeg', return_value='ffmpeg'), \
patch('services.ffmpeg_utils.run_ffmpeg', new=AsyncMock(return_value=(returncode, b'', b'encode failed'))) as encode, \
patch('services.gpu_gateway.decide'), \
patch.object(router, '_resolve_default_language', return_value='ru'), \
patch.object(router, '_run_chapter', side_effect=cached_chapter), \
patch.object(router, 'prune_cache_dir', side_effect=lambda p: prune_cache_dir(p, max_bytes=1)):
events = [e async for e in router._render_longform_sse(
parse_audiobook_script('# Chapter\nText'), default_voice=None)]
self.assertTrue(wav.exists())
self.assertEqual(encode.call_args.kwargs['timeout'], 21600.0)
self.assertEqual(any('"type": "done"' in e for e in events), returncode == 0, events)
self.assertEqual(any('"type": "error"' in e for e in events), returncode != 0, events)
if __name__ == '__main__':
unittest.main()
+63
View File
@@ -0,0 +1,63 @@
"""Device enrollment and revocation. No credential is embedded in the APK."""
import hashlib
import secrets
import sqlite3
import time
import uuid
from contextlib import contextmanager
class DeviceStore:
def __init__(self, path):
self.path = str(path)
with self.connect() as db:
db.execute("CREATE TABLE IF NOT EXISTS devices (id TEXT PRIMARY KEY, token_hash TEXT UNIQUE NOT NULL, label TEXT NOT NULL, revoked INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL)")
db.execute("CREATE TABLE IF NOT EXISTS pairing_codes (code_hash TEXT PRIMARY KEY, expires_at INTEGER NOT NULL, used INTEGER NOT NULL DEFAULT 0)")
@contextmanager
def connect(self):
connection = sqlite3.connect(self.path, timeout=30)
try:
with connection:
yield connection
finally:
connection.close()
@staticmethod
def digest(value):
return hashlib.sha256(value.encode('utf-8')).hexdigest()
def create_code(self, lifetime=600):
code = secrets.token_urlsafe(24)
with self.connect() as db:
db.execute("DELETE FROM pairing_codes WHERE expires_at < ? OR used=1", (int(time.time()),))
db.execute("INSERT INTO pairing_codes VALUES (?, ?, 0)", (self.digest(code), int(time.time()) + lifetime))
return code
def enroll(self, code, label):
if not isinstance(code, str) or not 20 <= len(code) <= 128:
raise PermissionError('Invalid pairing code')
token = secrets.token_urlsafe(48)
device_id = uuid.uuid4().hex
with self.connect() as db:
db.execute('BEGIN IMMEDIATE')
updated = db.execute('UPDATE pairing_codes SET used=1 WHERE code_hash=? AND used=0 AND expires_at>=?',
(self.digest(code), int(time.time())))
if updated.rowcount != 1:
raise PermissionError('Pairing code expired or already used')
db.execute('INSERT INTO devices VALUES (?, ?, ?, 0, ?)',
(device_id, self.digest(token), str(label)[:100], int(time.time())))
return {'deviceId': device_id, 'token': token}
def authenticate(self, token):
if not token or len(token) > 256:
raise PermissionError('Missing device credential')
with self.connect() as db:
row = db.execute('SELECT id FROM devices WHERE token_hash=? AND revoked=0', (self.digest(token),)).fetchone()
if row is None:
raise PermissionError('Invalid device credential')
return row[0]
def revoke(self, device_id):
with self.connect() as db:
db.execute('UPDATE devices SET revoked=1 WHERE id=?', (device_id,))
+25
View File
@@ -0,0 +1,25 @@
"""Run via SSH: manage_devices.py pair | list | revoke DEVICE_ID.
Pairing codes expire in ten minutes and must be entered in Android Settings.
"""
import argparse
import os
from pathlib import Path
from device_auth import DeviceStore
parser = argparse.ArgumentParser()
parser.add_argument('action', choices=['pair', 'list', 'revoke'])
parser.add_argument('device_id', nargs='?')
args = parser.parse_args()
root = Path(os.environ.get('AUDIOBOOK_SERVICE_ROOT', '/home/sevenhill/apps/aletheia-audiobook'))
store = DeviceStore(root / 'audiobook-jobs/jobs.db3')
if args.action == 'pair':
print(store.create_code())
elif args.action == 'revoke':
if not args.device_id:
parser.error('device_id is required')
store.revoke(args.device_id)
else:
with store.connect() as db:
for row in db.execute('SELECT id,label,revoked,created_at FROM devices ORDER BY created_at'):
print('\t'.join(map(str, row)))
+3 -3
View File
@@ -1,4 +1,4 @@
imageio-ffmpeg==0.6.0 fastapi>=0.115,<1
uvicorn>=0.30,<1
num2words==0.5.14 num2words==0.5.14
python-multipart==0.0.32 python-multipart>=0.0.18,<1
+80
View File
@@ -0,0 +1,80 @@
"""Real GPU/API smoke test, run on CMP. Uses and revokes a disposable device."""
import hashlib
import io
import json
import os
from pathlib import Path
import time
import zipfile
import httpx
from device_auth import DeviceStore
root = Path(os.environ.get('AUDIOBOOK_SERVICE_ROOT', '/home/sevenhill/apps/aletheia-audiobook'))
base = 'https://argus.kusoft.xyz/aletheia-tts'
with httpx.Client(timeout=60) as client:
for attempt in range(30):
try:
health = client.get(base + '/health')
if health.status_code == 200:
break
except httpx.TransportError:
pass
time.sleep(2)
else:
raise RuntimeError('Public receiver health endpoint is unavailable')
store = DeviceStore(root / 'audiobook-jobs/jobs.db3')
response = client.post(base + '/v1/devices/enroll', json={'code': store.create_code(), 'label': 'Deployment smoke test'})
response.raise_for_status()
device = response.json()
headers = {'Authorization': 'Bearer ' + device['token']}
job = None
try:
text = 'Это проверка изготовления аудиокниги. Приёмник получил книгу от приложения и читает её вслух. Готовая запись возвращается в библиотеку.'
book_format = os.environ.get('AUDIOBOOK_SMOKE_FORMAT', 'epub')
if book_format == 'epub':
archive = io.BytesIO()
with zipfile.ZipFile(archive, 'w') as epub:
epub.writestr('mimetype', 'application/epub+zip')
epub.writestr('META-INF/container.xml', '<container xmlns="urn:oasis:names:tc:opendocument:xmlns:container" version="1.0"><rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles></container>')
chapters = int(os.environ.get('AUDIOBOOK_SMOKE_CHAPTERS', '1'))
manifest = ''.join(f'<item id="c{i}" href="chapter{i}.xhtml" media-type="application/xhtml+xml"/>' for i in range(chapters))
spine = ''.join(f'<itemref idref="c{i}"/>' for i in range(chapters))
epub.writestr('OEBPS/content.opf', '<package xmlns="http://www.idpf.org/2007/opf" version="2.0" unique-identifier="bookid"><metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:identifier id="bookid">cmp-smoke</dc:identifier><dc:title>Проверка CMP</dc:title><dc:language>ru</dc:language></metadata><manifest>' + manifest + '</manifest><spine>' + spine + '</spine></package>')
for i in range(chapters):
body = text if chapters == 1 else text + f' Раздел номер {i + 1}.'
epub.writestr(f'OEBPS/chapter{i}.xhtml', '<html xmlns="http://www.w3.org/1999/xhtml"><head><title>Проверка</title></head><body><h1>Проверка ' + str(i + 1) + '</h1><p>' + body + '</p></body></html>')
book = archive.getvalue()
else:
book = ('<?xml version="1.0" encoding="UTF-8"?><FictionBook><body><section><title><p>Проверка</p></title><p>' + text + '</p></section></body></FictionBook>').encode()
response = client.post(base + '/v1/audiobooks', headers=headers, data={'title': 'Проверка CMP', 'author': 'Aletheia'}, files={'book': ('test.' + book_format, book, 'application/epub+zip' if book_format == 'epub' else 'application/xml')})
response.raise_for_status()
job = response.json()['id']
path = base + '/v1/audiobooks/' + job
assert client.get(path).status_code == 401
deadline = time.monotonic() + 1200
previous = None
while time.monotonic() < deadline:
response = client.get(path, headers=headers)
response.raise_for_status()
status = response.json()
state = (status['status'], status['stage'], status['processedCharacters'], status['totalCharacters'])
if state != previous:
print(json.dumps({'state': state}, ensure_ascii=False), flush=True)
previous = state
if status['status'] == 'ready':
response = client.get(path + '/file', headers=headers)
response.raise_for_status()
assert b'ftyp' in response.content[:32] and status['durationMs'] > 0
output = root / 'smoke-audiobook.m4a'
output.write_bytes(response.content)
print(json.dumps({'result': 'PASS', 'bytes': len(response.content), 'durationMs': status['durationMs'], 'sha256': hashlib.sha256(response.content).hexdigest()}), flush=True)
break
if status['status'] in ('failed', 'cancelled'):
raise RuntimeError(status)
time.sleep(3)
else:
raise TimeoutError('Synthesis exceeded smoke test timeout')
finally:
if job:
client.delete(base + '/v1/audiobooks/' + job, headers=headers)
store.revoke(device['deviceId'])
+63
View File
@@ -0,0 +1,63 @@
import os
import tempfile
import unittest
from unittest.mock import patch
from pathlib import Path
# A separate temporary service root and disabled worker prevent any GPU/service action.
_root = tempfile.TemporaryDirectory()
os.environ['AUDIOBOOK_SERVICE_ROOT'] = _root.name
os.environ['AUDIOBOOK_DISABLE_WORKER'] = '1'
import audiobook_service as service
from fastapi.testclient import TestClient
class ApiOwnershipTests(unittest.TestCase):
def setUp(self):
self.health = patch('voicestudio_client.check_available')
self.health.start()
self.addCleanup(self.health.stop)
self.client = TestClient(service.app)
self.one = self.enroll('first')
self.two = self.enroll('second')
def tearDown(self):
self.client.close()
def enroll(self, name):
response = self.client.post('/v1/devices/enroll', json={'code': service.devices.create_code(), 'label': name})
self.assertEqual(200, response.status_code)
return {'Authorization': 'Bearer ' + response.json()['token']}
def test_owner_is_required_for_status_download_and_delete(self):
response = self.client.post('/v1/audiobooks', headers=self.one,
data={'title': 'Fixture', 'author': 'Test'}, files={'book': ('fixture.fb2', b'<FictionBook/>', 'application/xml')})
self.assertEqual(200, response.status_code, response.text)
job_id = response.json()['id']
path = '/v1/audiobooks/' + job_id
self.assertEqual(401, self.client.get(path).status_code)
self.assertEqual(200, self.client.get(path, headers=self.one).status_code)
self.assertEqual(404, self.client.get(path, headers=self.two).status_code)
self.assertEqual(404, self.client.get(path + '/file', headers=self.two).status_code)
self.assertEqual(404, self.client.delete(path, headers=self.two).status_code)
self.assertEqual(200, self.client.delete(path, headers=self.one).status_code)
self.assertEqual(404, self.client.get(path, headers=self.one).status_code)
def test_health_does_not_disclose_job_id_and_legacy_token_is_rejected(self):
self.assertNotIn('activeJobId', self.client.get('/health').json())
response = self.client.post('/v1/audiobooks', headers={'Authorization': 'Bearer legacy-shared-key'},
data={'title': 'Test'}, files={'book': ('a.fb2', b'<FictionBook/>')})
self.assertEqual(401, response.status_code)
def test_enrollment_code_cannot_be_replayed(self):
code = service.devices.create_code()
self.assertEqual(200, self.client.post('/v1/devices/enroll', json={'code': code}).status_code)
self.assertEqual(403, self.client.post('/v1/devices/enroll', json={'code': code}).status_code)
def tearDownModule():
_root.cleanup()
if __name__ == '__main__':
unittest.main()
+98
View File
@@ -0,0 +1,98 @@
import os
from pathlib import Path
import sqlite3
import tempfile
import unittest
from unittest.mock import patch
import audio_cleanup as cleanup
class CleanupTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.outputs = self.root / 'outputs'
self.outputs.mkdir()
self.db = self.root / 'jobs.db3'
self.voice_db = self.root / 'voice.db3'
for name, value in [('DATABASE', self.db), ('OUTPUTS', self.outputs), ('VOICE_DATABASE', self.voice_db)]:
p = patch.object(cleanup, name, value)
p.start()
self.addCleanup(p.stop)
with sqlite3.connect(self.db) as db:
db.execute('CREATE TABLE jobs(id TEXT, output_path TEXT, status TEXT)')
with sqlite3.connect(self.voice_db) as db:
db.execute('CREATE TABLE jobs(status TEXT)')
cleanup.initialize()
self.local = self.root / 'job/audiobook.m4a'
self.local.parent.mkdir()
self.local.write_bytes(b'complete mp4')
self.output = self.outputs / 'audiobook_test.m4b'
self.output.write_bytes(self.local.read_bytes())
self.work = self.outputs / 'audiobook_test'
self.work.mkdir()
self.wav = self.outputs / ('longform_cache/' + 'a' * 20 + '.wav')
self.wav.parent.mkdir()
self.wav.write_bytes(b'chapter')
self.segment = self.wav.parent / ('segments/' + 'b' * 20 + '.wav')
self.segment.parent.mkdir()
self.segment.write_bytes(b'segment')
os.utime(self.segment, (1, 1))
(self.work / 'concat.txt').write_text("file '" + str(self.wav) + "'\n")
with cleanup.connect() as db:
db.execute('INSERT INTO jobs VALUES(?,?,?)', ('job', str(self.local), 'ready'))
def acknowledge(self):
with cleanup.connect() as db:
db.execute("DELETE FROM jobs WHERE id='job'")
def test_keeps_everything_until_phone_acknowledges_then_cleans(self):
other = self.outputs / 'audiobook_other.m4b'
other.write_bytes(b'other book')
cleanup.remember_output(self.local, self.output)
cleanup.sweep()
self.assertTrue(self.output.exists())
self.assertTrue(self.wav.exists())
self.acknowledge()
cleanup.sweep()
for path in [self.output, self.work, self.wav, self.segment]:
self.assertFalse(path.exists(), path)
self.assertTrue(other.exists())
self.assertTrue(self.local.exists())
cleanup.sweep() # restart/retry is idempotent
def test_active_generation_defers_cleanup(self):
cleanup.remember_output(self.local, self.output)
self.acknowledge()
with cleanup.connect() as db:
db.execute("INSERT INTO jobs VALUES('next', '', 'synthesizing')")
cleanup.sweep()
self.assertTrue(self.output.exists())
with cleanup.connect() as db:
db.execute("DELETE FROM jobs WHERE id='next'")
cleanup.sweep()
self.assertFalse(self.output.exists())
def test_legacy_worker_is_matched_by_content_not_title(self):
cleanup.capture_legacy_outputs()
self.acknowledge()
self.local.unlink()
cleanup.sweep()
self.assertFalse(self.output.exists())
def test_rejects_path_escape_and_retains_acknowledgement_on_error(self):
cleanup.remember_output(self.local, self.output)
self.acknowledge()
outside = self.root / 'keep.wav'
outside.write_bytes(b'keep')
(self.work / 'concat.txt').write_text("file '" + str(outside) + "'\n")
with self.assertRaises(ValueError):
cleanup.sweep()
self.assertTrue(outside.exists())
with cleanup.connect() as db:
self.assertEqual(0, db.execute('SELECT completed FROM delivered_audio_cleanup').fetchone()[0])
if __name__ == '__main__':
unittest.main()
+43
View File
@@ -0,0 +1,43 @@
import tempfile
import unittest
from pathlib import Path
from device_auth import DeviceStore
class DeviceAuthTests(unittest.TestCase):
def setUp(self):
self.root = tempfile.TemporaryDirectory()
self.store = DeviceStore(Path(self.root.name) / 'auth.db')
def tearDown(self):
self.root.cleanup()
def test_pairing_is_single_use_and_devices_have_independent_credentials(self):
code = self.store.create_code()
first = self.store.enroll(code, 'one')
with self.assertRaises(PermissionError):
self.store.enroll(code, 'replay')
second = self.store.enroll(self.store.create_code(), 'two')
self.assertNotEqual(first['token'], second['token'])
self.assertEqual(first['deviceId'], self.store.authenticate(first['token']))
self.store.revoke(first['deviceId'])
with self.assertRaises(PermissionError):
self.store.authenticate(first['token'])
self.assertEqual(second['deviceId'], self.store.authenticate(second['token']))
def test_expired_pairing_and_unknown_tokens_are_rejected(self):
with self.assertRaises(PermissionError):
self.store.enroll(self.store.create_code(lifetime=-1), 'expired')
with self.assertRaises(PermissionError):
self.store.authenticate('old-shared-apk-token')
def test_database_contains_hashes_not_bearer_tokens(self):
enrolled = self.store.enroll(self.store.create_code(), 'phone')
with self.store.connect() as db:
value = db.execute('SELECT token_hash FROM devices').fetchone()[0]
self.assertNotEqual(enrolled['token'], value)
self.assertEqual(64, len(value))
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,39 @@
import io
import json
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
import voicestudio_client as adapter
class VoiceStudioTests(unittest.TestCase):
def test_bounded_parts_preserve_text_order(self):
text = ('Это длинный раздел книги. ' * 2000).strip()
parts = adapter.split_sections([('Глава', text)])
self.assertGreater(len(parts), 20)
self.assertTrue(all(0 < len(part) <= 1600 for _, part in parts))
self.assertEqual(text, ' '.join(part for _, part in parts))
self.assertEqual([], adapter.split_sections([('Пустая', ' ')]))
def test_stream_progress_and_completed_container(self):
with tempfile.TemporaryDirectory() as root:
outputs = Path(root)
audio = b'\x00\x00\x00\x18ftypM4A ' + b'\0' * 128
(outputs / 'result.m4b').write_bytes(audio)
events = [{'type': 'chapter', 'index': 0, 'total': 1},
{'type': 'assembling'},
{'type': 'done', 'output': 'result.m4b', 'duration_s': 1, 'failed_chapters': []}]
stream = io.BytesIO(b''.join(('data: ' + json.dumps(e) + '\n\n').encode() for e in events))
seen = []
with patch('audio_cleanup.remember_output') as remember, patch.object(adapter, 'check_available'), patch.object(adapter, 'OUTPUTS', outputs), patch.object(adapter, 'urlopen', return_value=stream):
adapter.render_audiobook([('Глава', 'Текст')], 'Книга', 'Автор', outputs / 'book.m4a', seen.append, lambda: False)
remember.assert_called_once_with(outputs / 'book.m4a', outputs / 'result.m4b')
self.assertEqual(events, seen)
self.assertEqual(audio, (outputs / 'book.m4a').read_bytes())
def test_failed_chapters_never_become_ready(self):
stream = io.BytesIO(b'data: {"type":"done","failed_chapters":[1]}\n')
with patch.object(adapter, 'check_available'), patch.object(adapter, 'urlopen', return_value=stream):
with self.assertRaises(RuntimeError):
adapter.render_audiobook([('Chapter', 'Text')], 'Book', '', Path('unused.m4a'), lambda event: None, lambda: False)
+77
View File
@@ -0,0 +1,77 @@
"""Adapter to the existing local VoiceStudio audiobook API; no local TTS model."""
import json
import os
import re
from pathlib import Path
import shutil
from urllib.request import Request, urlopen
BASE = os.environ.get('VOICESTUDIO_URL', 'http://127.0.0.1:3900').rstrip('/')
VOICE = os.environ.get('VOICESTUDIO_VOICE', 'db7928a3')
OUTPUTS = Path(os.environ.get('VOICESTUDIO_OUTPUTS', '/home/sevenhill/voicestudio-data/outputs'))
def split_sections(sections, limit=1600):
"""Bound GPU tasks while preserving all non-whitespace text and its order."""
result = []
for heading, text in sections:
text = re.sub(r'\s+', ' ', text).strip()
parts = []
while len(text) > limit:
cut = max((match.end() for match in re.finditer(r'[.!?…]\s+', text[:limit])
if match.end() >= limit // 2), default=0)
if not cut:
cut = text.rfind(' ', 0, limit + 1)
if cut <= 0:
cut = limit
parts.append(text[:cut].strip())
text = text[cut:].lstrip()
if text:
parts.append(text)
for index, part in enumerate(parts, 1):
label = heading if len(parts) == 1 else f'{heading} · {index}/{len(parts)}'
result.append((label, part))
return result
def check_available():
with urlopen(BASE + '/health', timeout=3) as response:
if response.status != 200:
raise RuntimeError('VoiceStudio unavailable')
def render_audiobook(sections, title, author, destination, on_event, cancelled):
check_available()
payload = {
'text': '\n\n'.join('# ' + heading.replace('\n', ' ') + '\n' + text for heading, text in sections),
'default_voice': VOICE, 'language': 'ru', 'format': 'm4b',
'metadata': {'title': title, 'author': author},
}
request = Request(BASE + '/audiobook', data=json.dumps(payload).encode(),
headers={'Content-Type': 'application/json'})
# Assembly may take up to six hours on CMP, with no SSE events meanwhile.
with urlopen(request, timeout=28800) as response:
for line in response:
if cancelled():
raise RuntimeError('Изготовление отменено')
if not line.startswith(b'data:'):
continue
event = json.loads(line[5:].strip())
if event.get('type') == 'error':
raise RuntimeError(event.get('message') or event.get('error') or str(event))
on_event(event)
if event.get('type') == 'done':
if event.get('failed_chapters'):
raise RuntimeError('VoiceStudio не озвучил все главы')
source = (OUTPUTS / event['output']).resolve()
if not source.is_relative_to(OUTPUTS.resolve()) or not source.is_file():
raise RuntimeError('VoiceStudio вернул недоступный файл')
# M4B and M4A are the same MP4/AAC container; preserve embedded chapters.
with source.open('rb') as audio:
if b'ftyp' not in audio.read(32):
raise RuntimeError('VoiceStudio вернул повреждённый аудиофайл')
shutil.copyfile(source, destination)
from audio_cleanup import remember_output
remember_output(destination, source)
return event
raise RuntimeError('VoiceStudio завершил поток без готовой аудиокниги')
+12
View File
@@ -0,0 +1,12 @@
"""Disposable CUDA process; the API continues serving uploads and downloads."""
import os
import sys
os.environ['AUDIOBOOK_WORKER_PROCESS'] = '1'
from audiobook_service import run_job, HTTPException
if __name__ == '__main__':
try:
run_job(sys.argv[1])
except HTTPException as error:
if error.status_code != 404:
raise
Binary file not shown.
-5
View File
@@ -1,5 +0,0 @@
KeystorePath=C:\Users\seven\.aletheia\signing\aletheia-release.jks
Alias=aletheia-release
StorePassword=a^*FHWV5Qs#5ER9QGknKj$BJzx%w
KeyPassword=a^*FHWV5Qs#5ER9QGknKj$BJzx%w
StoreType=PKCS12