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
# Android / Gradle
.kotlin/
signing/*.jks
signing/*-info.txt
.gradle/
build/
**/build/
+5 -10
View File
@@ -1,13 +1,7 @@
plugins {
plugins {
id("com.android.application")
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 =
"\"${value.replace("\\", "\\\\").replace("\"", "\\\"")}\""
@@ -19,15 +13,14 @@ android {
applicationId = "com.aletheia.app"
minSdk = 24
targetSdk = 36
versionCode = 50
versionName = "2.39"
versionCode = 55
versionName = "2.44"
buildConfigField(
"String",
"AUDIOBOOK_API_BASE_URL",
quotedBuildConfig("https://argus.kusoft.xyz/aletheia-tts/")
)
buildConfigField("String", "AUDIOBOOK_API_TOKEN", quotedBuildConfig(audiobookApiToken))
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -76,6 +69,8 @@ android {
}
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.appcompat:appcompat:1.7.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.POST_NOTIFICATIONS" />
<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" />
<application
@@ -28,6 +30,15 @@
android:resource="@xml/file_paths" />
</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
android:name=".audiobook.AudioBookGenerationService"
android:exported="false"
@@ -13,6 +13,7 @@ import com.aletheia.app.ui.reader.ReaderStateCoordinator
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import xyz.kusoft.argusupdater.ArgusUpdateManager
@@ -47,6 +48,12 @@ class AletheiaApplication : Application() {
lateinit var appUpdateManager: ArgusUpdateManager
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() {
super.onCreate()
@@ -57,9 +64,20 @@ class AletheiaApplication : Application() {
settingsRepository = SettingsRepository(this, databaseHelper)
bookParserService = BookParserService(this)
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)
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)
}
}
@@ -33,7 +33,7 @@ class AudioBookGenerationService : Service() {
private val jobs = ConcurrentHashMap<Long, Job>()
private val userCancellations = ConcurrentHashMap.newKeySet<Long>()
private val app by lazy { application as AletheiaApplication }
private val client by lazy { RemoteAudioBookClient() }
private val client by lazy { RemoteAudioBookClient(this) }
override fun onCreate() {
super.onCreate()
@@ -76,6 +76,7 @@ class AudioBookGenerationService : Service() {
try {
var remoteJobId = record.remoteJobId
if (remoteJobId.isNullOrBlank()) {
client.checkAvailable()
record = saveProgress(
record.copy(
status = AudioBook.Status.GENERATING,
@@ -87,19 +88,25 @@ class AudioBookGenerationService : Service() {
)
val created = client.create(source, book.title, book.author)
remoteJobId = created.id
record = saveProgress(record.copy(remoteJobId = remoteJobId))
record = record.copy(remoteJobId = remoteJobId)
record = saveProgress(record)
}
var consecutiveFailures = 0
while (true) {
val remote = try {
client.status(remoteJobId)
} catch (error: IOException) {
consecutiveFailures = (consecutiveFailures + 1).coerceAtMost(4)
if (!AudioRetryPolicy.shouldRetry(error, consecutiveFailures)) throw error
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
}
consecutiveFailures = 0
record = saveProgress(
record.copy(
@@ -108,7 +115,8 @@ class AudioBookGenerationService : Service() {
totalCharacters = remote.totalCharacters,
durationMs = remote.durationMs,
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
)
)
@@ -127,24 +135,39 @@ class AudioBookGenerationService : Service() {
partial.delete()
record = saveProgress(record.copy(currentChapter = "Скачивание готового M4A…"))
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()
check(partial.renameTo(output)) { "Не удалось сохранить готовый M4A-файл" }
record = saveProgress(
record.copy(
filePath = output.absolutePath,
remoteJobId = null,
status = AudioBook.Status.READY,
processedCharacters = record.totalCharacters,
currentChapter = null
)
)
runCatching { client.cancelOrDelete(remoteJobId) }
.onFailure { Log.w(TAG, "Remote job cleanup failed for id=$remoteJobId", it) }
app.audioJobCleanup.enqueue(remoteJobId)
app.audioJobCleanup.flush()
notifyComplete(record)
} catch (cancelled: CancellationException) {
if (app.audioBookRepository.getById(audioBookId) == null) {
partial.delete()
output.delete()
}
if (userCancellations.remove(audioBookId)) {
record.remoteJobId?.let { runCatching { client.cancelOrDelete(it) } }
record.remoteJobId?.let(app.audioJobCleanup::enqueue)
app.audioJobCleanup.flush()
partial.delete()
if (app.audioBookRepository.getById(audioBookId) != null) {
saveProgress(record.copy(status = AudioBook.Status.CANCELLED, currentChapter = null))
@@ -168,7 +191,8 @@ class AudioBookGenerationService : Service() {
}
scope.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()
if (app.audioBookRepository.getById(audioBookId) != null) {
saveProgress(record.copy(status = AudioBook.Status.CANCELLED, currentChapter = null))
@@ -177,13 +201,19 @@ class AudioBookGenerationService : Service() {
}
}
private fun saveProgress(audioBook: AudioBook): AudioBook =
app.audioBookRepository.save(audioBook).also(::broadcastProgress)
private fun saveProgress(audioBook: AudioBook): AudioBook {
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) {
"queued" -> "В очереди на CMP-сервере…"
"extracting" -> "Подготовка текста книги…"
"loading_model" -> "Загрузка мужского голоса Qwen…"
"loading_model" -> "Подготовка голоса VoiceStudio…"
"synthesizing" -> "Создание аудиокниги…"
"encoding" -> "Сборка итогового M4A…"
"ready" -> "Аудиокнига готова"
@@ -196,6 +226,7 @@ class AudioBookGenerationService : Service() {
}
private fun fail(record: AudioBook, message: String) {
if (app.audioBookRepository.getById(record.id) == null) return
val failed = saveProgress(
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) =
NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_reader_v2_voice)
.setContentTitle("Создание аудиокниги · Qwen, мужской голос")
.setContentTitle("Создание аудиокниги · VoiceStudio")
.setContentText(detail)
.setOnlyAlertOnce(true)
.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
class RemoteAudioBookClient(
private val baseUrl: String = BuildConfig.AUDIOBOOK_API_BASE_URL,
private val token: String = BuildConfig.AUDIOBOOK_API_TOKEN
context: android.content.Context,
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 {
require(source.isFile) { "Файл исходной книги не найден" }
val boundary = "aletheia-${UUID.randomUUID()}"
@@ -56,9 +83,10 @@ class RemoteAudioBookClient(
}
try {
if (connection.responseCode !in 200..299) {
throw IOException(connection.errorDescription())
throw AudioHttpException(connection.responseCode)
}
destination.parentFile?.mkdirs()
val expectedLength = connection.contentLengthLong
connection.inputStream.buffered(256 * 1024).use { input ->
FileOutputStream(destination).buffered(256 * 1024).use { output ->
input.copyTo(output, 256 * 1024)
@@ -67,26 +95,36 @@ class RemoteAudioBookClient(
check(destination.isFile && destination.length() > 0L) {
"Сервер вернул пустой M4A-файл"
}
if (expectedLength >= 0 && destination.length() != expectedLength) {
throw IOException("Аудиофайл получен не полностью. Скачивание будет повторено")
}
java.io.RandomAccessFile(destination, "rw").use { it.fd.sync() }
} finally {
connection.disconnect()
}
}
fun cancelOrDelete(jobId: String) {
fun cancelOrDelete(jobId: String): Boolean {
val connection = open("v1/audiobooks/$jobId").apply { requestMethod = "DELETE" }
try {
val code = connection.responseCode
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 {
connection.disconnect()
}
}
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 {
connectTimeout = CONNECT_TIMEOUT_MS
readTimeout = READ_TIMEOUT_MS
@@ -98,7 +136,7 @@ class RemoteAudioBookClient(
private fun HttpURLConnection.readSuccessfulBody(): String {
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() }
}
@@ -11,7 +11,7 @@ import com.aletheia.app.model.ReadingBookmark
import com.aletheia.app.model.ReadingNote
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) {
db.execSQL(
@@ -32,7 +32,8 @@ class AppDatabaseHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_N
last_read INTEGER NOT NULL,
total_pages INTEGER NOT NULL DEFAULT 0,
current_page INTEGER NOT NULL DEFAULT 0,
remote_id TEXT
remote_id TEXT,
catalog_source TEXT
)
""".trimIndent()
)
@@ -63,6 +64,7 @@ class AppDatabaseHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_N
createBookmarksTable(db)
createNotesTable(db)
createAudioBooksTable(db)
createCatalogIndex(db)
}
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")) {
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(
@@ -162,6 +170,48 @@ class AppDatabaseHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_N
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(
"audiobooks", null, null, null, null, null, "updated_at DESC"
).use { cursor ->
@@ -181,6 +231,7 @@ class AppDatabaseHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_N
return if (audioBook.id == 0L) {
writableDatabase.insertWithOnConflict("audiobooks", null, values, SQLiteDatabase.CONFLICT_REPLACE)
} else {
values.remove("position_ms") // Playback owns this field independently of generation/metadata.
writableDatabase.update("audiobooks", values, "id = ?", arrayOf(audioBook.id.toString()))
audioBook.id
}
@@ -403,6 +454,7 @@ class AppDatabaseHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_N
put("total_pages", book.totalPages)
put("current_page", book.currentPage)
put("remote_id", book.remoteId)
put("catalog_source", book.catalogSource)
}
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")),
totalPages = cursor.getInt(cursor.getColumnIndexOrThrow("total_pages")),
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(
@@ -590,9 +643,13 @@ class AppDatabaseHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_N
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 {
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
}
}
@@ -7,19 +7,23 @@ import java.io.File
class AudioBookRepository(
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)
fun createOrReset(book: Book): AudioBook {
@Synchronized fun createOrReset(book: Book): AudioBook {
val existing = databaseHelper.getAudioBookBySourceBookId(book.id)
existing?.remoteJobId?.let(cleanup::enqueue)
cleanup.flush()
existing?.filePath?.takeIf(String::isNotBlank)?.let { File(it).delete() }
val now = System.currentTimeMillis()
val audioBook = AudioBook(
@@ -33,32 +37,43 @@ class AudioBookRepository(
updatedAt = now
)
val id = databaseHelper.saveAudioBook(audioBook)
databaseHelper.updateAudioPosition(id, 0)
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()))
return requireNotNull(databaseHelper.getAudioBookById(id))
}
fun savePosition(id: Long, positionMs: Long) {
val current = databaseHelper.getAudioBookById(id) ?: return
databaseHelper.saveAudioBook(
current.copy(positionMs = positionMs.coerceAtLeast(0), updatedAt = System.currentTimeMillis())
)
databaseHelper.updateAudioPosition(id, positionMs)
}
fun outputFile(audioBookId: Long): File = File(outputDirectory, "audiobook-$audioBookId.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() }
partialOutputFile(audioBook.id).delete()
databaseHelper.deleteAudioBook(audioBook.id)
cleanup.flush()
}
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) {
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 {
return try {
val xml = File(book.filePath).readText()
val document = parseXml(ByteArrayInputStream(xml.toByteArray()))
val document = File(book.filePath).inputStream().use(::parseXml)
val title = document.getElementsByTagNameNS("*", "book-title").item(0)?.textContent?.trim().orEmpty()
val authorNode = document.getElementsByTagNameNS("*", "author").item(0) as? Element
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 getBookByRemoteId(remoteId: String): Book? =
databaseHelper.getAllBooks().firstOrNull { it.remoteId == remoteId }
fun catalogSource(): String = settingsRepository.getCatalogUrl().trimEnd('/')
fun getBookByRemoteId(remoteId: String, source: String = catalogSource()): Book? =
databaseHelper.getBookByRemoteId(source, remoteId)
fun importBook(uri: Uri): Book {
val book = parserService.parseAndStoreBookFromUri(uri)
@@ -36,9 +38,11 @@ class BookRepository(
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 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)
return requireNotNull(databaseHelper.getBookById(id))
}
@@ -57,12 +61,11 @@ class BookRepository(
}
fun updateBook(book: Book) {
databaseHelper.saveBook(book)
databaseHelper.updateMetadata(book)
}
fun saveLocations(bookId: Long, locations: String) {
val book = getBookById(bookId) ?: return
updateBook(book.copy(locations = locations))
databaseHelper.updateLocations(bookId, locations)
}
fun saveProgress(
@@ -73,25 +76,7 @@ class BookRepository(
currentPage: Int,
totalPages: Int
) {
val book = getBookById(bookId) ?: return
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
)
)
databaseHelper.updateReadingState(bookId, progress, cfi, chapter, currentPage, totalPages)
}
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 lock = Any()
fun encode(book: QBooksBook): String = book.toJson().toString()
fun decode(json: String): QBooksBook? = runCatching { JSONObject(json).toBook() }.getOrNull()
data class CachedPage(
val page: CatalogPage,
val savedAtMillis: Long
@@ -120,6 +123,7 @@ class CatalogCache(context: Context) {
private fun QBooksBook.toJson(): JSONObject = JSONObject().apply {
put("id", id)
put("catalogSource", catalogSource)
put("title", title)
put("author", author)
put("format", format)
@@ -139,6 +143,7 @@ class CatalogCache(context: Context) {
if (id.isBlank() || title.isBlank()) return null
return QBooksBook(
id = id,
catalogSource = optString("catalogSource"),
title = title,
author = optString("author").trim().ifBlank { "Неизвестный автор" },
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.CatalogPage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.File
@@ -56,6 +57,12 @@ class QBooksService(
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) {
runCatching {
configure(url, username, password)
@@ -88,7 +95,8 @@ class QBooksService(
): CatalogCache.CachedPage? = withContext(Dispatchers.IO) {
configureFromSettings()
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,
includeDetails: Boolean = false
): QBooksBook = withContext(Dispatchers.IO) {
if (book.catalogSource.isNotBlank() && book.catalogSource != settingsRepository.getCatalogUrl().trimEnd('/'))
return@withContext book
configureFromSettings()
val cacheKey = bookCacheKey(book.id)
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) {
if (book.catalogSource != null && book.catalogSource != settingsRepository.getCatalogUrl().trimEnd('/')) return@withContext book
if (!book.needsCatalogMetadata()) return@withContext book
configureFromSettings()
val synthetic = QBooksBook(
@@ -157,6 +168,7 @@ class QBooksService(
runCatching {
configureFromSettings()
val query = searchQuery.trim()
val source = settingsRepository.getCatalogUrl().trimEnd('/')
val catalogPage = when (detectBackend()) {
Backend.QBOOKS -> {
val books = getBooksFromQBooks(query, page, maxOf(pageSize, DEFAULT_PAGE_SIZE))
@@ -169,7 +181,7 @@ class QBooksService(
Backend.UNKNOWN -> error(
"Сервер не поддерживает книжный каталог. Проверьте адрес каталога."
)
}
}.let { it.copy(books = it.books.map { book -> book.copy(catalogSource = source) }) }
runCatching {
catalogCache.writePage(catalogCacheKey(query, page, pageUrl), catalogPage)
catalogPage.books.forEach { book ->
@@ -190,6 +202,9 @@ class QBooksService(
var destination: File? = null
runCatching {
configureFromSettings()
check(book.catalogSource.isBlank() || book.catalogSource == requireBaseUrl()) {
"Каталог изменился. Откройте книгу в исходном каталоге."
}
if (!booksDirectory.exists()) {
booksDirectory.mkdirs()
}
@@ -209,6 +224,7 @@ class QBooksService(
FileOutputStream(target).use { output ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
while (true) {
kotlinx.coroutines.currentCoroutineContext().ensureActive()
val read = input.read(buffer)
if (read == -1) break
output.write(buffer, 0, read)
@@ -225,6 +241,7 @@ class QBooksService(
Result.Success(target)
}.getOrElse { throwable ->
destination?.takeIf(File::exists)?.delete()
if (throwable is kotlinx.coroutines.CancellationException) throw throwable
Result.Failure(qBooksConnectionError("Не удалось скачать книгу.", throwable), throwable)
}
}
@@ -19,8 +19,8 @@ data class AudioBook(
val updatedAt: Long = System.currentTimeMillis()
) {
val progressPercent: Int
get() = if (totalCharacters <= 0) 0 else
((processedCharacters * 100L) / totalCharacters).toInt().coerceIn(0, 100)
get() = if (status == Status.READY) 100 else if (totalCharacters <= 0) 0 else
((processedCharacters.toDouble() / totalCharacters) * 100).toInt().coerceIn(0, 99)
enum class Status(val databaseValue: String) {
QUEUED("queued"), GENERATING("generating"), READY("ready"),
@@ -16,7 +16,8 @@ data class Book(
val lastRead: Long = System.currentTimeMillis(),
val totalPages: Int = 0,
val currentPage: Int = 0,
val remoteId: String? = null
val remoteId: String? = null,
val catalogSource: String? = null
) {
val progressText: String
get() = "${(readingProgress * 100).toInt()}%"
@@ -12,5 +12,6 @@ data class QBooksBook(
val description: String? = null,
val language: String? = null,
val publisher: String? = null,
val published: String? = null
val published: String? = null,
val catalogSource: String = ""
) : java.io.Serializable
@@ -3,7 +3,18 @@ package com.aletheia.app.ui.audiobook
import android.content.Context
import android.content.Intent
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.Handler
import android.os.Looper
@@ -26,11 +37,13 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONArray
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
class AudioBookPlayerActivity : AppCompatActivity() {
private lateinit var binding: ActivityAudioBookPlayerBinding
private val app by lazy { application as AletheiaApplication }
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 chapters: List<Chapter> = emptyList()
private var prepared = false
@@ -67,12 +80,17 @@ class AudioBookPlayerActivity : AppCompatActivity() {
override fun onDestroy() {
handler.removeCallbacksAndMessages(null)
mediaPlayer?.release()
controllerFuture?.let(MediaController::releaseFuture)
mediaPlayer = null
super.onDestroy()
}
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.playerPlayButton.setOnClickListener { togglePlayback() }
binding.playerRewindButton.setOnClickListener { seekBy(-15_000) }
@@ -100,7 +118,7 @@ class AudioBookPlayerActivity : AppCompatActivity() {
override fun onStartTrackingTouch(seekBar: SeekBar?) { userSeeking = true }
override fun onStopTrackingTouch(seekBar: SeekBar?) {
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
updateProgress()
}
@@ -131,37 +149,45 @@ class AudioBookPlayerActivity : AppCompatActivity() {
}
private fun preparePlayer(audio: AudioBook) {
mediaPlayer = MediaPlayer().apply {
setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
)
setDataSource(audio.filePath)
setOnPreparedListener { player ->
prepared = true
player.seekTo(audio.positionMs.coerceAtMost(player.duration.toLong()).toInt())
applySpeed()
val future = MediaController.Builder(this, SessionToken(this,
ComponentName(this, AudioPlaybackService::class.java))).buildAsync()
controllerFuture = future
future.addListener({
if (isFinishing || isDestroyed) return@addListener
try {
val controller = future.get()
mediaPlayer = controller
controller.addListener(object : Player.Listener {
override fun onEvents(player: Player, events: Player.Events) {
prepared = player.duration > 0
updateProgress()
}
setOnCompletionListener {
binding.playerPlayButton.text = "▶"
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()
savePosition()
}
setOnErrorListener { _, _, _ ->
Toast.makeText(this@AudioBookPlayerActivity, "Не удалось воспроизвести M4A-файл", Toast.LENGTH_LONG).show()
true
}
prepareAsync()
} catch (error: Exception) {
Toast.makeText(this, "Не удалось подключить плеер", Toast.LENGTH_LONG).show()
}
}, ContextCompat.getMainExecutor(this))
}
private fun togglePlayback() {
val player = mediaPlayer ?: 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 "▶"
}
@@ -181,7 +207,7 @@ class AudioBookPlayerActivity : AppCompatActivity() {
val speed = SPEEDS[speedIndex]
binding.playerSpeedButton.text = "${speed.toDisplay()}x"
if (prepared) runCatching {
mediaPlayer?.playbackParams = mediaPlayer!!.playbackParams.setSpeed(speed)
mediaPlayer?.setPlaybackSpeed(speed)
}
}
@@ -191,8 +217,8 @@ class AudioBookPlayerActivity : AppCompatActivity() {
MaterialAlertDialogBuilder(this)
.setTitle("Таймер сна")
.setItems(labels) { _, index ->
sleepAtMs = minutes[index].takeIf { it > 0 }?.let { System.currentTimeMillis() + it * 60_000L }
binding.playerSleepButton.alpha = if (sleepAtMs == null) 1f else 0.45f
mediaPlayer?.sendCustomCommand(SessionCommand(AudioPlaybackService.SLEEP_COMMAND, Bundle.EMPTY),
Bundle().apply { putInt("minutes", minutes[index]) })
}
.show()
}
@@ -200,17 +226,17 @@ class AudioBookPlayerActivity : AppCompatActivity() {
private fun updateProgress() {
val player = mediaPlayer ?: return
if (!prepared) return
sleepAtMs?.let { deadline ->
if (System.currentTimeMillis() >= deadline) {
if (player.isPlaying) player.pause()
binding.playerPlayButton.text = "▶"
sleepAtMs = null
binding.playerSleepButton.alpha = 1f
}
}
val deadline = player.sessionExtras.getLong(AudioPlaybackService.SLEEP_DEADLINE)
val remaining = if (deadline == 0L) 0L else (deadline - SystemClock.elapsedRealtime()).coerceAtLeast(0)
binding.playerSleepButton.text = if (remaining == 0L) "◴" else "${(remaining + 59999) / 60000}м"
binding.playerSleepButton.contentDescription = if (remaining == 0L) "Установить таймер сна" else "Таймер сна: ${formatTime(remaining)}"
binding.playerPlayButton.text = if (player.isPlaying) "Ⅱ" else "▶"
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 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.playerRemaining.text = "-${formatTime((duration - position).toLong())}"
binding.playerRemainingSummary.text = remainingSummary((duration - position).toLong())
@@ -218,9 +244,7 @@ class AudioBookPlayerActivity : AppCompatActivity() {
}
private fun savePosition() {
val id = audioBook?.id ?: return
val position = mediaPlayer?.currentPosition?.toLong() ?: return
app.applicationScope.launch { app.audioBookRepository.savePosition(id, position) }
// The playback service owns persistence, including while this screen is stopped.
}
private fun parseChapters(json: String): List<Chapter> = runCatching {
@@ -49,6 +49,7 @@ class BookshelfFragment : Fragment() {
private val binding get() = _binding!!
private val app by lazy { requireActivity().application as AletheiaApplication }
private lateinit var audioBooksAdapter: BooksAdapter
private lateinit var booksAdapter: BooksAdapter
private lateinit var qBooksShelfAdapter: QBooksShelfAdapter
private var currentBooks: List<Book> = emptyList()
@@ -59,6 +60,9 @@ class BookshelfFragment : Fragment() {
private val metadataJobs = mutableListOf<Job>()
private var loadBooksJob: Job? = null
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(
ActivityResultContracts.RequestPermission()
@@ -98,6 +102,7 @@ class BookshelfFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
pendingAudioExportId = savedInstanceState?.getLong("audio.export.id") ?: 0L
booksAdapter = BooksAdapter(
onBookClicked = ::openBook,
@@ -110,6 +115,16 @@ class BookshelfFragment : Fragment() {
binding.booksRecycler.layoutManager = LinearLayoutManager(requireContext(), RecyclerView.HORIZONTAL, false)
binding.booksRecycler.addItemDecoration(BookshelfRailDecoration(requireContext(), dp(12), dp(8), dp(8)))
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(
onBookClicked = ::openQBooksBookPreview,
@@ -145,7 +160,10 @@ class BookshelfFragment : Fragment() {
continueReadingBook?.let(::openBook)
}
binding.clearLibrarySearchButton.setOnClickListener {
libraryFilter = LibraryFilter.ALL
binding.libraryFilterGroup.check(R.id.library_filter_all)
binding.booksSearchInput.setText("")
renderFilteredBooks()
}
binding.libraryFilterGroup.setOnCheckedStateChangeListener { _, checkedIds ->
libraryFilter = when (checkedIds.firstOrNull()) {
@@ -167,6 +185,7 @@ class BookshelfFragment : Fragment() {
override fun onResume() {
super.onResume()
resumeAudioBooks()
loadBooks()
}
@@ -187,11 +206,15 @@ class BookshelfFragment : Fragment() {
override fun onHiddenChanged(hidden: Boolean) {
super.onHiddenChanged(hidden)
if (!hidden && _binding != null) {
resumeAudioBooks()
loadBooks()
}
}
override fun onDestroyView() {
generationDialog?.dismiss()
generationDialog = null
generationDialogId = null
loadBooksJob?.cancel()
loadBooksJob = null
metadataJobs.forEach { it.cancel() }
@@ -200,6 +223,16 @@ class BookshelfFragment : Fragment() {
_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() {
loadBooksJob?.cancel()
loadBooksJob = viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
@@ -214,6 +247,7 @@ class BookshelfFragment : Fragment() {
result.onSuccess { (books, audioBooks) ->
currentBooks = books
currentAudioBooks = audioBooks
audioBooks.firstOrNull { it.id == generationDialogId }?.let(::updateGenerationDialog)
renderFilteredBooks()
enrichMissingMetadata(books.take(METADATA_PREFETCH_LIMIT))
}.onFailure { exception ->
@@ -244,8 +278,9 @@ class BookshelfFragment : Fragment() {
visibleBooks = filterBooks(currentBooks, query)
visibleAudioBooks = filterAudioBooks(currentAudioBooks, query)
val covers = currentBooks.associate { it.id to it.coverImage }
booksAdapter.submitContent(
visibleBooks,
booksAdapter.submitList(visibleBooks)
audioBooksAdapter.submitContent(
emptyList(),
visibleAudioBooks.map { it to covers[it.sourceBookId] }
)
renderLibraryState(
@@ -274,11 +309,12 @@ class BookshelfFragment : Fragment() {
val isLibraryEmpty = allBooks.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.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.audioBookshelfPanel.visibility = if (displayedAudioBooks.isNotEmpty()) View.VISIBLE else View.GONE
binding.continueReadingCard.visibility =
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) {
PopupMenu(requireContext(), anchor).apply {
when (audioBook.status) {
@@ -460,6 +526,13 @@ class BookshelfFragment : Fragment() {
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 {
confirmDeleteAudioBook(audioBook)
true
@@ -476,8 +549,8 @@ class BookshelfFragment : Fragment() {
MaterialAlertDialogBuilder(requireContext())
.setTitle("Создать аудиокнигу?")
.setMessage(
warning + "Книга будет отправлена на ваш CUDA-сервер и озвучена мужским голосом Qwen. " +
"Приложение покажет прогресс и скачает готовый M4A в свой кэш. Создание может занять продолжительное время."
warning + "Книга будет отправлена на CMP и озвучена мужским голосом VoiceStudio. " +
"Приложение покажет прогресс и сохранит готовый M4A в библиотеке. Создание может занять продолжительное время."
)
.setPositiveButton("Начать") { _, _ -> startAudioBookGeneration(book) }
.setNegativeButton(R.string.action_cancel, null)
@@ -485,32 +558,67 @@ class BookshelfFragment : Fragment() {
}
private fun startAudioBookGeneration(book: Book) {
if (!startingAudioBooks.add(book.id)) return
val context = requireContext().applicationContext
if (Build.VERSION.SDK_INT >= 33 &&
ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.POST_NOTIFICATIONS) !=
android.content.pm.PackageManager.PERMISSION_GRANTED
) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
val record = app.audioBookRepository.createOrReset(book)
withContext(Dispatchers.Main) {
if (_binding == null) return@withContext
AudioBookGenerationService.start(requireContext(), record.id)
viewLifecycleOwner.lifecycleScope.launch {
try {
val record = withContext(Dispatchers.IO) {
com.aletheia.app.audiobook.RemoteAudioBookClient(context).checkAvailable()
app.audioBookRepository.createOrReset(book).also {
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) {
val chapter = audioBook.currentChapter?.let { "\nСейчас: $it" }.orEmpty()
MaterialAlertDialogBuilder(requireContext())
generationDialog?.dismiss()
generationDialogId = audioBook.id
generationDialog = MaterialAlertDialogBuilder(requireContext())
.setTitle("Создание аудиокниги · ${audioBook.progressPercent}%")
.setMessage("Обработано ${audioBook.processedCharacters} из ${audioBook.totalCharacters} символов.$chapter")
.setMessage("Подключение к CMP…")
.setPositiveButton("Продолжить в фоне", null)
.setNegativeButton("Отменить") { _, _ ->
AudioBookGenerationService.cancel(requireContext(), audioBook.id)
}
.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) {
@@ -64,9 +64,10 @@ class HomeFragment : Fragment() {
binding.homeSearch.setOnClickListener { openSearch() }
binding.catalogAll.setOnClickListener { openSearch() }
binding.downloadedAll.setOnClickListener { openMyBooks() }
listOf(binding.chipGenres, binding.chipRecommendations, binding.chipBooks, binding.chipAuthors).forEach { chip ->
chip.setOnClickListener { openSearch() }
}
binding.chipGenres.setOnClickListener { openCatalogMode("genres") }
binding.chipRecommendations.setOnClickListener { openCatalogMode("selection") }
binding.chipBooks.setOnClickListener { openCatalogMode("new") }
binding.chipAuthors.setOnClickListener { openCatalogMode("authors") }
binding.homeBannerFirst.setOnClickListener { openBanner(0) }
binding.homeBannerSecond.setOnClickListener { openBanner(1) }
binding.homeBannerThird.setOnClickListener { openBanner(2) }
@@ -215,6 +216,11 @@ class HomeFragment : Fragment() {
startActivity(BookDetailActivity.createIntent(requireContext(), book))
}
private fun openCatalogMode(mode: String) {
parentFragmentManager.setFragmentResult("catalog-mode", Bundle().apply { putString("mode", mode) })
openSearch()
}
private fun openSearch() {
(activity as? MainActivity)?.selectTab(R.id.nav_search)
}
@@ -49,6 +49,20 @@ class BookDetailActivity : AppCompatActivity() {
binding.backButton.setOnClickListener { finish() }
binding.shareButton.setOnClickListener { shareBook() }
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()
loadState()
loadMetadataAndCover()
@@ -118,7 +132,7 @@ class BookDetailActivity : AppCompatActivity() {
private fun loadState() {
lifecycleScope.launch {
localBook = withContext(Dispatchers.IO) { app.bookRepository.getBookByRemoteId(book.id) }
localBook = withContext(Dispatchers.IO) { app.bookRepository.getBookByRemoteId(book.id, book.catalogSource) }
updatePrimaryAction()
}
}
@@ -127,7 +141,7 @@ class BookDetailActivity : AppCompatActivity() {
if (isDownloading) return
val downloaded = localBook
binding.downloadProgress.visibility = View.GONE
binding.primaryAction.isEnabled = true
binding.primaryAction.isEnabled = book.catalogSource != "legacy"
binding.primaryAction.text = getString(
if (downloaded == null) R.string.book_detail_download else R.string.book_detail_read
)
@@ -139,54 +153,17 @@ class BookDetailActivity : AppCompatActivity() {
}
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 {
val result = withContext(Dispatchers.IO) {
runCatching {
app.bookRepository.getBookByRemoteId(book.id)?.let { return@runCatching it }
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()
}
}
}
localBook = app.bookDownloads.start(book).await()
isDownloading = false
result.onSuccess {
localBook = it
updatePrimaryAction()
}.onFailure {
} catch (error: Exception) {
if (error is kotlinx.coroutines.CancellationException) throw error
isDownloading = false
updatePrimaryAction()
MaterialAlertDialogBuilder(this@BookDetailActivity)
.setTitle(R.string.dialog_error_title)
.setMessage(getString(R.string.dialog_download_book_failed, it.message ?: it.javaClass.simpleName))
.setPositiveButton(R.string.action_ok, null)
.show()
MaterialAlertDialogBuilder(this@BookDetailActivity).setTitle(R.string.dialog_error_title)
.setMessage(error.message).setPositiveButton(R.string.action_ok, null).show()
}
}
}
@@ -198,7 +175,7 @@ class BookDetailActivity : AppCompatActivity() {
private fun shareBook() {
lifecycleScope.launch {
val downloadedBook = withContext(Dispatchers.IO) {
app.bookRepository.getBookByRemoteId(book.id)
app.bookRepository.getBookByRemoteId(book.id, book.catalogSource)
}
runCatching {
if (downloadedBook == null) {
@@ -217,22 +194,15 @@ class BookDetailActivity : AppCompatActivity() {
}
private fun toggleFavorite() {
val prefs = getSharedPreferences(FAVORITES_PREFS, MODE_PRIVATE)
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()
app.catalogFavorites.toggle(book)
renderFavorite()
}
private fun renderFavorite() {
val selected = getSharedPreferences(FAVORITES_PREFS, MODE_PRIVATE)
.getStringSet(FAVORITES_KEY, emptySet())
.orEmpty()
.contains(book.id)
val selected = app.catalogFavorites.contains(book)
binding.favoriteButton.text = if (selected) "♥" else "♡"
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")
@@ -15,6 +15,15 @@ class QBooksCatalogAdapter(
) : RecyclerView.Adapter<QBooksCatalogAdapter.QBooksBookViewHolder>() {
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>) {
val oldItems = items.toList()
@@ -22,7 +31,7 @@ class QBooksCatalogAdapter(
override fun getOldListSize(): Int = oldItems.size
override fun getNewListSize(): Int = books.size
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 =
oldItems[oldItemPosition].hasSameVisibleContent(books[newItemPosition])
@@ -34,9 +43,9 @@ class QBooksCatalogAdapter(
}
fun appendDistinct(books: List<QBooksBook>) {
val existingIds = items.map { it.id }.toHashSet()
val existingIds = items.map { it.identity() }.toHashSet()
val startIndex = items.size
val newItems = books.filter { existingIds.add(it.id) }
val newItems = books.filter { existingIds.add(it.identity()) }
if (newItems.isEmpty()) {
return
}
@@ -48,7 +57,7 @@ class QBooksCatalogAdapter(
fun appendOrUpdate(books: List<QBooksBook>) {
val merged = items.toMutableList()
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
}
submitList(merged)
@@ -64,7 +73,7 @@ class QBooksCatalogAdapter(
}
fun updateBook(book: QBooksBook) {
val index = items.indexOfFirst { it.id == book.id }
val index = items.indexOfFirst { it.identity() == book.identity() }
if (index < 0) return
items[index] = book
notifyItemChanged(index)
@@ -101,11 +110,24 @@ class QBooksCatalogAdapter(
R.string.a11y_download_qbooks_book,
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.downloadButton.setOnClickListener { onDownloadClicked(book) }
}
}
private fun QBooksBook.identity() = catalogSource + "\n" + id
private fun QBooksBook.hasSameVisibleContent(other: QBooksBook): Boolean =
title == other.title &&
author == other.author &&
@@ -42,8 +42,11 @@ class QBooksLibraryFragment : Fragment() {
private var searchJob: Job? = null
private var loadJob: Job? = null
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 navigationJob: Job? = null
private var configuredSource: String? = null
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, state: Bundle?): View {
_binding = FragmentQbooksLibraryBinding.inflate(inflater, container, false)
@@ -82,6 +85,24 @@ class QBooksLibraryFragment : Fragment() {
binding.refreshCatalogButton.setOnClickListener { 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(
binding.genreFantasy to R.string.genre_fantasy,
binding.genreDetective to R.string.genre_detective,
@@ -91,24 +112,99 @@ class QBooksLibraryFragment : Fragment() {
binding.genreScience to R.string.genre_science
)
genreChips.forEach { (chip, title) ->
chip.setOnClickListener { binding.searchInput.setText(getString(title)) }
chip.setOnClickListener { selectMode("genres", getString(title)) }
}
}
override fun onResume() {
super.onResume()
initializeCatalog()
app.bookDownloads.refresh(booksAdapter.currentItems())
if (catalogMode == "favorites") loadBooks(reset = true)
}
override fun onDestroyView() {
searchJob?.cancel()
loadJob?.cancel()
navigationJob?.cancel()
coverJobs.forEach { it.cancel() }
coverJobs.clear()
_binding = null
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() {
var url = app.settingsRepository.getCatalogUrl()
var validation = QBooksUrlPolicy.validate(url)
@@ -123,13 +219,21 @@ class QBooksLibraryFragment : Fragment() {
return
}
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
binding.notConfiguredCard.visibility = View.GONE
binding.errorCard.visibility = View.GONE
binding.searchCard.visibility = View.VISIBLE
binding.qbooksActionsRow.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) {
@@ -151,8 +255,20 @@ class QBooksLibraryFragment : Fragment() {
val generation = requestGeneration
val pageToLoad = currentPage
val pageUrl = if (reset) null else nextPageUrl
val pageUrl = if (reset) browseUrl else nextPageUrl
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
loadJob = viewLifecycleOwner.lifecycleScope.launch {
val cached = app.qBooksService.getCachedCatalogPage(query, pageToLoad, PAGE_SIZE, pageUrl)
@@ -195,7 +311,14 @@ class QBooksLibraryFragment : Fragment() {
pageIndex: 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)
nextPageUrl = page.nextPageUrl
hasMoreBooks = !page.nextPageUrl.isNullOrBlank()
@@ -253,48 +376,18 @@ class QBooksLibraryFragment : Fragment() {
}
private fun downloadBook(book: QBooksBook) {
if (!activeDownloads.add(book.id)) return
setDownloadStatus(getString(R.string.qbooks_download_status, book.title), true)
viewLifecycleOwner.lifecycleScope.launch {
val result = withContext(Dispatchers.IO) {
runCatching {
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)
val existing = withContext(Dispatchers.IO) {
app.bookRepository.getBookByRemoteId(book.id, book.catalogSource.ifBlank { app.bookRepository.catalogSource() })
}
if (existing != null) { openDownloadedBook(existing); return@launch }
try {
app.bookRepository.importDownloadedBook(
temp,
buildDownloadFileName(book),
book.id,
book.coverImage
)
} finally {
temp.takeIf(File::exists)?.delete()
}
}
}
activeDownloads.remove(book.id)
if (_binding == null) return@launch
result.onSuccess { downloaded ->
setDownloadStatus(getString(R.string.qbooks_download_complete), false)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.dialog_ready_title)
.setMessage(getString(R.string.dialog_book_added_message, book.title))
.setPositiveButton(R.string.action_read) { _, _ -> openDownloadedBook(downloaded) }
.setNegativeButton(R.string.action_ok, null)
.show()
}.onFailure {
setDownloadStatus("", false)
showMessage(getString(R.string.dialog_download_book_failed, it.message ?: it.javaClass.simpleName))
val downloaded = app.bookDownloads.start(book).await()
com.google.android.material.snackbar.Snackbar.make(binding.root, R.string.qbooks_download_complete, 6000)
.setAction(R.string.action_read) { openDownloadedBook(downloaded) }.show()
} catch (error: Exception) {
if (error is kotlinx.coroutines.CancellationException) throw error
if (_binding != null) showMessage(error.message ?: "Не удалось скачать книгу")
}
}
}
@@ -903,21 +903,8 @@ class ReaderActivity : AppCompatActivity() {
binding.colorPinkButton.isSelected = pendingHighlightColor == COLOR_PINK
}
private fun shareSelection() {
val selection = currentSelection ?: return
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 shareSelection() { currentSelection?.let { selectionActions.share(it.text) } }
private fun copySelection() { currentSelection?.let { selectionActions.copy(it.text) } }
private fun openNoteEditor() {
val selection = currentSelection ?: return
@@ -1350,42 +1337,10 @@ class ReaderActivity : AppCompatActivity() {
}
}
private suspend fun persistPreferences(snapshot: ReaderPreferences) {
withContext(Dispatchers.IO) {
app.readerPersistenceMutex.withLock {
snapshot.persist(app.settingsRepository)
}
}
}
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 val persistence by lazy { ReaderPersistence(app) }
private val selectionActions by lazy { ReaderSelectionActions(this) }
private suspend fun persistPreferences(snapshot: ReaderPreferences) = persistence.preferences(snapshot)
private suspend fun persistPosition(snapshot: ReaderPosition, book: Book) = persistence.position(book.id, snapshot)
private fun persistStateThenApplyOrientation() {
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(
val left: 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.audioPairButton.setOnClickListener { pairAudioService() }
binding.testConnectionButton.setOnClickListener { testConnection() }
binding.saveSettingsButton.setOnClickListener { saveSettings(showNotification = true) }
binding.checkUpdatesButton.setOnClickListener { checkForUpdates() }
@@ -110,6 +111,34 @@ class SettingsFragment : Fragment() {
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() {
val settings = app.settingsRepository
val storedTheme = settings.getString(SettingsRepository.KEY_THEME, "sepia")
+23 -23
View File
@@ -181,7 +181,7 @@
<ImageButton
android:id="@+id/reader_back_button"
android:layout_width="56dp"
android:layout_height="match_parent"
android:layout_height="56dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/reader_v2_back"
android:padding="17dp"
@@ -377,7 +377,7 @@
<ImageButton
android:id="@+id/settings_back_button"
android:layout_width="48dp"
android:layout_height="match_parent"
android:layout_height="48dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/reader_v2_back"
android:padding="13dp"
@@ -397,7 +397,7 @@
<ImageButton
android:id="@+id/settings_close_button"
android:layout_width="48dp"
android:layout_height="match_parent"
android:layout_height="48dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/reader_v2_close"
android:padding="13dp"
@@ -509,7 +509,7 @@
android:layout_marginEnd="2dp"
android:layout_weight="1"
android:background="@drawable/bg_reader_v2_theme_light"
android:minWidth="0dp"
android:minWidth="48dp"
android:fontFamily="sans"
android:maxLines="1"
android:singleLine="true"
@@ -535,7 +535,7 @@
android:layout_marginEnd="2dp"
android:layout_weight="1"
android:background="@drawable/bg_reader_v2_theme_sepia"
android:minWidth="0dp"
android:minWidth="48dp"
android:fontFamily="sans"
android:maxLines="1"
android:singleLine="true"
@@ -560,7 +560,7 @@
android:layout_marginStart="2dp"
android:layout_weight="1"
android:background="@drawable/bg_reader_v2_theme_dark"
android:minWidth="0dp"
android:minWidth="48dp"
android:fontFamily="sans"
android:maxLines="1"
android:singleLine="true"
@@ -1335,7 +1335,7 @@
android:background="@drawable/bg_reader_v2_theme_light"
android:fontFamily="sans"
android:maxLines="1"
android:minWidth="0dp"
android:minWidth="48dp"
android:singleLine="true"
android:paddingStart="8dp"
android:paddingEnd="8dp"
@@ -1361,7 +1361,7 @@
android:background="@drawable/bg_reader_v2_theme_sepia"
android:fontFamily="sans"
android:maxLines="1"
android:minWidth="0dp"
android:minWidth="48dp"
android:singleLine="true"
android:paddingStart="8dp"
android:paddingEnd="8dp"
@@ -1386,7 +1386,7 @@
android:background="@drawable/bg_reader_v2_theme_dark"
android:fontFamily="sans"
android:maxLines="1"
android:minWidth="0dp"
android:minWidth="48dp"
android:singleLine="true"
android:paddingStart="8dp"
android:paddingEnd="8dp"
@@ -1418,7 +1418,7 @@
android:layout_width="match_parent"
android:layout_height="52dp"
android:background="@drawable/bg_reader_v2_mode_selectable"
android:minWidth="0dp"
android:minWidth="48dp"
android:text="@string/reader_v2_page_turn_tap_swipe"
android:textAllCaps="false"
android:textColor="@color/reader_v2_text"
@@ -1431,7 +1431,7 @@
android:layout_width="match_parent"
android:layout_height="52dp"
android:background="@drawable/bg_reader_v2_mode_selectable"
android:minWidth="0dp"
android:minWidth="48dp"
android:text="@string/reader_v2_page_turn_swipe"
android:textAllCaps="false"
android:textColor="@color/reader_v2_text"
@@ -1444,7 +1444,7 @@
android:layout_width="match_parent"
android:layout_height="52dp"
android:background="@drawable/bg_reader_v2_mode_selectable"
android:minWidth="0dp"
android:minWidth="48dp"
android:text="@string/reader_v2_page_turn_tap"
android:textAllCaps="false"
android:textColor="@color/reader_v2_text"
@@ -1517,7 +1517,7 @@
android:layout_height="match_parent"
android:layout_weight="1"
android:background="@drawable/bg_reader_v2_tab"
android:minWidth="0dp"
android:minWidth="48dp"
android:text="@string/reader_v2_tab_toc"
android:textColor="@color/reader_v2_text"
android:textSize="12sp"
@@ -1530,7 +1530,7 @@
android:layout_height="match_parent"
android:layout_weight="1"
android:background="@drawable/bg_reader_v2_tab"
android:minWidth="0dp"
android:minWidth="48dp"
android:text="@string/reader_v2_tab_bookmarks"
android:textColor="@color/reader_v2_secondary_on_light"
android:textSize="12sp"
@@ -1543,7 +1543,7 @@
android:layout_height="match_parent"
android:layout_weight="1"
android:background="@drawable/bg_reader_v2_tab"
android:minWidth="0dp"
android:minWidth="48dp"
android:text="@string/reader_v2_tab_quotes"
android:textColor="@color/reader_v2_secondary_on_light"
android:textSize="12sp"
@@ -1553,7 +1553,7 @@
<ImageButton
android:id="@+id/contents_close_button"
android:layout_width="48dp"
android:layout_height="match_parent"
android:layout_height="58dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/reader_v2_close"
android:padding="13dp"
@@ -1703,7 +1703,7 @@
<ImageButton
android:id="@+id/selection_close_button"
android:layout_width="52dp"
android:layout_height="match_parent"
android:layout_height="52dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/reader_v2_close"
android:padding="14dp"
@@ -1715,7 +1715,7 @@
android:layout_width="match_parent"
android:layout_height="56dp"
android:gravity="start|center_vertical"
android:minWidth="0dp"
android:minWidth="48dp"
android:paddingStart="20dp"
android:text="@string/reader_v2_share"
android:textAllCaps="false"
@@ -1738,7 +1738,7 @@
android:layout_width="match_parent"
android:layout_height="56dp"
android:gravity="start|center_vertical"
android:minWidth="0dp"
android:minWidth="48dp"
android:paddingStart="20dp"
android:text="@string/reader_v2_copy"
android:textAllCaps="false"
@@ -1761,7 +1761,7 @@
android:layout_width="match_parent"
android:layout_height="56dp"
android:gravity="start|center_vertical"
android:minWidth="0dp"
android:minWidth="48dp"
android:paddingStart="20dp"
android:text="@string/reader_v2_add_note"
android:textAllCaps="false"
@@ -1858,7 +1858,7 @@
android:id="@+id/save_quote_button"
android:layout_width="match_parent"
android:layout_height="56dp"
android:minWidth="0dp"
android:minWidth="48dp"
android:text="@string/reader_v2_save_quote"
android:textColor="@color/reader_v2_text"
android:textSize="16sp"
@@ -1967,7 +1967,7 @@
android:layout_height="match_parent"
android:layout_marginEnd="6dp"
android:layout_weight="1"
android:minWidth="0dp"
android:minWidth="48dp"
android:text="@string/reader_v2_cancel"
android:textColor="@color/reader_v2_primary"
app:backgroundTint="@color/reader_v2_separator"
@@ -1979,7 +1979,7 @@
android:layout_height="match_parent"
android:layout_marginStart="6dp"
android:layout_weight="1"
android:minWidth="0dp"
android:minWidth="48dp"
android:text="@string/reader_v2_save"
android:textColor="@color/reader_v2_canvas"
app:backgroundTint="@color/reader_v2_accent"
@@ -153,6 +153,40 @@
</LinearLayout>
</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
android:id="@+id/continue_reading_card"
android:layout_width="match_parent"
@@ -77,6 +77,15 @@
android:textColor="@color/ink_color"
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
android:layout_width="match_parent"
android:layout_height="wrap_content"
@@ -31,6 +31,15 @@
android:lineSpacingExtra="2dp"
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
android:id="@+id/settings_overview_card"
android:layout_width="match_parent"
+12 -4
View File
@@ -67,16 +67,24 @@
</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
android:id="@+id/download_button"
style="@style/Widget.Aletheia.Button.Primary"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="8dp"
android:insetTop="0dp"
android:insetBottom="0dp"
android:minWidth="40dp"
android:minHeight="40dp"
android:minWidth="48dp"
android:minHeight="48dp"
android:padding="8dp"
app:icon="@drawable/ic_download"
app:iconGravity="textStart"
+3
View File
@@ -339,4 +339,7 @@
<string name="book_detail_favorite_remove">Убрать из избранного</string>
<string name="share_book_chooser_title">Поделиться книгой</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>
+1
View File
@@ -6,6 +6,7 @@
<exclude
domain="file"
path="Books" />
<exclude domain="file" path="Audiobooks" />
<exclude
domain="sharedpref"
path="." />
@@ -7,6 +7,7 @@
<exclude
domain="file"
path="Books" />
<exclude domain="file" path="Audiobooks" />
<exclude
domain="sharedpref"
path="." />
@@ -19,6 +20,7 @@
<exclude
domain="file"
path="Books" />
<exclude domain="file" path="Audiobooks" />
<exclude
domain="sharedpref"
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 += @(
(Join-Path $ProjectRoot "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
if ($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) {
& $gradleExePath assembleRelease --stacktrace
if ($LASTEXITCODE -ne 0) { throw "Release build failed: $LASTEXITCODE" }
}
$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
if ($LASTEXITCODE -ne 0) { throw "zipalign failed: $LASTEXITCODE" }
$storePassFile = Join-Path $env:TEMP "aletheia-store-pass.txt"
$keyPassFile = Join-Path $env:TEMP "aletheia-key-pass.txt"
$storePassFile = (New-TemporaryFile).FullName
$keyPassFile = (New-TemporaryFile).FullName
try {
Set-Content -LiteralPath $storePassFile -Value $keystoreInfo["StorePassword"] -NoNewline
@@ -345,6 +345,7 @@ try {
--v4-signing-enabled true `
--out $signedApk `
$alignedApk
if ($LASTEXITCODE -ne 0) { throw "APK signing failed: $LASTEXITCODE" }
}
finally {
foreach ($secretPath in @($storePassFile, $keyPassFile)) {
@@ -358,6 +359,7 @@ finally {
}
$verifyOutputLines = & $apksignerBat verify --verbose --print-certs --v4-signature-file $idsigPath $signedApk
if ($LASTEXITCODE -ne 0) { throw "APK verification failed: $LASTEXITCODE" }
$verifyOutput = $verifyOutputLines | Out-String
foreach ($requiredVerificationLine in @(
"Verified using v2 scheme (APK Signature Scheme v2): true",
+16 -2
View File
@@ -1,8 +1,8 @@
param(
[string]$ManifestPath,
[string]$SshHost = "192.168.0.25",
[string]$SshHost = "192.168.0.185",
[string]$SshUser = "sevenhill",
[string]$ArgusDataPath = "/opt/argus/data",
[string]$ArgusDataPath = "/mnt/data/argus",
[switch]$RestartArgus
)
@@ -206,11 +206,22 @@ shutil.copy2(source, target_path)
size_bytes = target_path.stat().st_size
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"
if source.suffix.lower() == ".apk":
content_type = "application/vnd.android.package-archive"
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:
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("BEGIN")
@@ -280,6 +291,8 @@ try:
)
conn.commit()
if conn.execute("PRAGMA integrity_check").fetchone()[0] != "ok":
raise RuntimeError("Argus database integrity check failed after publication")
except Exception:
conn.rollback()
try:
@@ -313,6 +326,7 @@ $remoteScriptLines = @(
"export ARGUS_PLATFORM=$(ConvertTo-BashSingleQuoted $platform)",
"export ARGUS_PACKAGE_KIND=$(ConvertTo-BashSingleQuoted $packageKind)",
"export ARGUS_NOTES=$(ConvertTo-BashSingleQuoted $releaseNotes)",
"export ARGUS_SHA256=$(ConvertTo-BashSingleQuoted $packageSha256)",
'trap ''rm -f -- "$SOURCE_FILE"'' EXIT',
'python3 - "$SOURCE_FILE" <<''PY''',
$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`
- API: `http://0.0.0.0:8765`
- jobs and SQLite state: `/home/sevenhill/apps/aletheia-audiobook/audiobook-jobs`
- API token: `/home/sevenhill/apps/aletheia-audiobook/service/service-token.txt`
- public route: `https://argus.kusoft.xyz/aletheia-tts/`
- voice: Qwen3-TTS 0.6B CustomVoice, `Ryan`, Russian
- Receiver: `/home/sevenhill/apps/aletheia-audiobook/service`
- Python: `/home/sevenhill/apps/VoiceStudio/.venv/bin/python`
- State: `/home/sevenhill/apps/aletheia-audiobook/audiobook-jobs/jobs.db3`
- VoiceStudio: `http://127.0.0.1:3900`, outputs in `~/voicestudio-data/outputs`
- Public API: `https://argus.kusoft.xyz/aletheia-tts/`
- 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
argus.kusoft.xyz {
encode zstd gzip
handle_path /aletheia-tts/* {
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.
Run `python -m unittest discover -s server/audiobook -p 'test_*.py' -v`.
`smoke_live.py`, run on CMP, performs real EPUB upload, VoiceStudio synthesis and
public M4A download with a disposable device, then revokes it. Set
`AUDIOBOOK_SMOKE_FORMAT=fb2` for the same test with FB2. Unit tests mock synthesis.
@@ -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
WorkingDirectory=/home/sevenhill/apps/aletheia-audiobook/service
Environment=AUDIOBOOK_SERVICE_ROOT=/home/sevenhill/apps/aletheia-audiobook
Environment=HF_HOME=/home/sevenhill/apps/qwen3-audiobook-test/hf
Environment=QWEN_TTS_MODEL_ID=Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice
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
Environment=VOICESTUDIO_VOICE=f24f004e
ExecStart=/home/sevenhill/apps/VoiceStudio/.venv/bin/python -m uvicorn audiobook_service:app --host 0.0.0.0 --port 8765 --workers 1
Restart=always
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)
+109 -331
View File
@@ -1,13 +1,11 @@
from __future__ import annotations
import gc
import hashlib
import hmac
import sys
from device_auth import DeviceStore
import html
import json
import os
import re
import secrets
import shutil
import sqlite3
import subprocess
@@ -15,68 +13,24 @@ import threading
import time
import uuid
import zipfile
from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager
from pathlib import Path, PurePosixPath
from typing import TYPE_CHECKING, Annotated, Any, Iterator
from xml.etree import ElementTree
import imageio_ffmpeg
import soundfile as sf
from fastapi import Depends, FastAPI, File, Form, Header, HTTPException, UploadFile
from fastapi.responses import FileResponse
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"))
SERVICE_ROOT = ROOT / "service"
DATA_ROOT = ROOT / "audiobook-jobs"
TOKEN_FILE = SERVICE_ROOT / "service-token.txt"
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
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)
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()
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")
app = FastAPI(title="Aletheia CMP Audiobook Service", version="2.0.0")
queue_condition = threading.Condition()
queued_jobs: list[str] = []
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(
"UPDATE jobs SET status='queued', stage='queued' "
"WHERE status IN ('extracting','synthesizing','encoding')"
@@ -128,10 +85,31 @@ def initialize_database() -> None:
queued_jobs.extend(row["id"] for row in rows)
def require_token(authorization: Annotated[str | None, Header()] = None) -> None:
expected = f"Bearer {API_TOKEN}"
if not authorization or not hmac.compare_digest(authorization, expected):
raise HTTPException(status_code=401, detail="Invalid API token")
devices = DeviceStore(DATABASE)
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:
@@ -170,17 +148,22 @@ def job_payload(row: sqlite3.Row) -> dict:
@app.get("/health")
def health() -> dict:
from voicestudio_client import check_available
try:
check_available()
except Exception:
raise HTTPException(status_code=503, detail="VoiceStudio unavailable")
return {
"status": "ok",
"workerMode": "lazy-model",
"activeJobId": worker_state["activeJobId"],
"workerMode": "voicestudio",
"modelLoaded": worker_state["modelLoaded"],
}
@app.post("/v1/audiobooks", dependencies=[Depends(require_token)])
@app.post("/v1/audiobooks")
async def create_audiobook(
book: Annotated[UploadFile, File()],
owner: Annotated[str, Depends(require_token)],
title: Annotated[str, Form()],
author: Annotated[str, Form()] = "",
) -> dict:
@@ -207,8 +190,8 @@ async def create_audiobook(
connection.execute(
"""
INSERT INTO jobs (
id,title,author,source_name,input_path,output_path,status,stage,created_at,updated_at
) VALUES (?,?,?,?,?,?,?,?,?,?)
id,title,author,source_name,input_path,output_path,status,stage,created_at,updated_at,owner_id
) VALUES (?,?,?,?,?,?,?,?,?,?,?)
""",
(
job_id,
@@ -221,6 +204,7 @@ async def create_audiobook(
"queued",
timestamp,
timestamp,
owner,
),
)
with queue_condition:
@@ -229,14 +213,14 @@ async def create_audiobook(
return job_payload(get_job(job_id))
@app.get("/v1/audiobooks/{job_id}", dependencies=[Depends(require_token)])
def audiobook_status(job_id: str) -> dict:
return job_payload(get_job(job_id))
@app.get("/v1/audiobooks/{job_id}")
def audiobook_status(job_id: str, owner: Annotated[str, Depends(require_token)]) -> dict:
return job_payload(owned_job(job_id, owner))
@app.get("/v1/audiobooks/{job_id}/file", dependencies=[Depends(require_token)])
def audiobook_file(job_id: str) -> FileResponse:
row = get_job(job_id)
@app.get("/v1/audiobooks/{job_id}/file")
def audiobook_file(job_id: str, owner: Annotated[str, Depends(require_token)]) -> FileResponse:
row = owned_job(job_id, owner)
output = Path(row["output_path"])
if row["status"] != "ready" or not output.is_file():
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")
@app.delete("/v1/audiobooks/{job_id}", dependencies=[Depends(require_token)])
def cancel_or_delete(job_id: str) -> dict:
row = get_job(job_id)
@app.delete("/v1/audiobooks/{job_id}")
def cancel_or_delete(job_id: str, owner: Annotated[str, Depends(require_token)]) -> dict:
row = owned_job(job_id, owner)
if row["status"] == "queued":
with queue_condition:
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"])
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:
from voicestudio_client import render_audiobook, split_sections
row = get_job(job_id)
job_root = Path(row["input_path"]).parent
chunks_root = job_root / "chunks"
chunks_root.mkdir(exist_ok=True)
models: list[Any] = []
try:
if cancellation_requested(job_id):
finish_cancellation(job_id, job_root)
return
update_job(job_id, status="extracting", stage="extracting", error_message=None)
input_path = Path(row["input_path"])
sections = extract_epub(input_path) if input_path.suffix.lower() == ".epub" else extract_fb2(input_path)
chunks = split_chunks(sections)
if not chunks:
raise RuntimeError("В книге не найден русский текст для озвучивания")
total = sum(len(chunk["text"]) for chunk in chunks)
manifest_path = job_root / "chunks.json"
manifest_path.write_text(json.dumps(chunks, ensure_ascii=False, indent=2), encoding="utf-8")
update_job(job_id, status="synthesizing", stage="loading_model", total_characters=total)
if cancellation_requested(job_id):
finish_cancellation(job_id, job_root)
return
pause_ollama_for_audiobook()
models = load_models()
import torch
source = Path(row["input_path"])
sections = extract_epub(source) if source.suffix.lower() == ".epub" else extract_fb2(source)
sections = split_sections(sections)
if not sections:
raise RuntimeError("В книге не найден текст для озвучивания")
total = sum(len(text) for _, text in sections)
update_job(job_id, status="synthesizing", stage="synthesizing", total_characters=total,
processed_characters=0, duration_ms=0, chapters_json="[]",
current_chapter="Проверка сохранённых фрагментов и озвучивание недостающих…")
completed = {}
verified = set()
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
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)
output = Path(row["output_path"])
encode_m4a(chunk_files, output, job_root)
result = render_audiobook(sections, row["title"], row["author"], Path(row["output_path"]),
on_event, lambda: cancellation_requested(job_id))
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),
)
update_job(job_id, status="ready", stage="ready", processed_characters=total,
duration_ms=round(float(result["duration_s"]) * 1000), current_chapter=None)
except Exception as error:
update_job(
job_id,
status="failed",
stage="failed",
error_message=f"{type(error).__name__}: {error}",
)
finally:
unload_models(models)
if cancellation_requested(job_id):
finish_cancellation(job_id, job_root)
else:
update_job(job_id, status="failed", stage="failed", error_message=f"{type(error).__name__}: {error}")
def worker_loop() -> None:
while True:
@@ -670,19 +442,25 @@ def worker_loop() -> None:
job_id = queued_jobs.pop(0)
worker_state["activeJobId"] = job_id
try:
env = dict(os.environ, AUDIOBOOK_WORKER_PROCESS="1")
process = subprocess.Popen([sys.executable, str(Path(__file__).with_name("worker_runner.py")), job_id], env=env)
while process.poll() is None:
try:
run_job(job_id)
except HTTPException as error:
if error.status_code != 404:
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:
worker_state["activeJobId"] = None
# Qwen/Triton can retain a CUDA context after Python references are
# 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)
worker_state["modelLoaded"] = False
initialize_database()
threading.Thread(target=worker_loop, name="audiobook-worker", daemon=True).start()
if os.environ.get("AUDIOBOOK_WORKER_PROCESS") != "1":
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
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