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