From 43170145dd3cfb42b3b0ebe4596febc15129e9bf Mon Sep 17 00:00:00 2001 From: Paul Woitaschek Date: Mon, 1 Aug 2022 18:03:26 +0200 Subject: [PATCH] Attempt to recover the old book positions. --- .../data/repo/internals/dao/LegacyBookDao.kt | 8 + .../kotlin/voice/app/scanner/BookParser.kt | 147 ++++++++++++++++++ .../kotlin/voice/app/scanner/ChapterParser.kt | 50 ++++++ .../kotlin/voice/app/scanner/MediaScanner.kt | 96 +----------- .../voice/app/scanner/MediaScannerTest.kt | 15 +- 5 files changed, 224 insertions(+), 92 deletions(-) create mode 100644 scanner/src/main/kotlin/voice/app/scanner/BookParser.kt create mode 100644 scanner/src/main/kotlin/voice/app/scanner/ChapterParser.kt diff --git a/data/src/main/kotlin/voice/data/repo/internals/dao/LegacyBookDao.kt b/data/src/main/kotlin/voice/data/repo/internals/dao/LegacyBookDao.kt index 80f0cd8120..15a56acb97 100644 --- a/data/src/main/kotlin/voice/data/repo/internals/dao/LegacyBookDao.kt +++ b/data/src/main/kotlin/voice/data/repo/internals/dao/LegacyBookDao.kt @@ -6,6 +6,8 @@ import voice.data.legacy.LegacyBookMetaData import voice.data.legacy.LegacyBookSettings import voice.data.legacy.LegacyBookmark import voice.data.legacy.LegacyChapter +import java.io.File +import java.util.UUID @Dao interface LegacyBookDao { @@ -19,12 +21,18 @@ interface LegacyBookDao { @Query("SELECT * FROM bookSettings") suspend fun settings(): List + @Query("SELECT * FROM bookSettings WHERE id = :id") + suspend fun settingsById(id: UUID): LegacyBookSettings? + @Query("SELECT * FROM chapters") suspend fun chapters(): List @Query("SELECT * FROM bookmark") suspend fun bookmarks(): List + @Query("SELECT * FROM bookmark WHERE file IN(:chapters)") + suspend fun bookmarksByFiles(chapters: List<@JvmSuppressWildcards File>): List + @Query("DELETE FROM bookmark") suspend fun deleteBookmarks() diff --git a/scanner/src/main/kotlin/voice/app/scanner/BookParser.kt b/scanner/src/main/kotlin/voice/app/scanner/BookParser.kt new file mode 100644 index 0000000000..d156524a89 --- /dev/null +++ b/scanner/src/main/kotlin/voice/app/scanner/BookParser.kt @@ -0,0 +1,147 @@ +package voice.app.scanner + +import android.app.Application +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import voice.common.BookId +import voice.data.Book +import voice.data.BookContent +import voice.data.Bookmark +import voice.data.Chapter +import voice.data.repo.BookContentRepo +import voice.data.repo.internals.dao.BookmarkDao +import voice.data.repo.internals.dao.LegacyBookDao +import voice.data.toUri +import voice.logging.core.Logger +import java.io.File +import java.time.Instant +import javax.inject.Inject + +class BookParser +@Inject constructor( + private val contentRepo: BookContentRepo, + private val mediaAnalyzer: MediaAnalyzer, + private val legacyBookDao: LegacyBookDao, + private val application: Application, + private val bookmarkDao: BookmarkDao, +) { + + suspend fun getOrPut(chapters: List, file: DocumentFile): BookContent { + val id = BookId(file.uri) + return contentRepo.getOrPut(id) { + val analyzed = mediaAnalyzer.analyze(chapters.first().id.toUri()) + + val filePath = file.uri.filePath() + val migrationMetaData = filePath?.let { + legacyBookDao.bookMetaData() + .find { metadata -> metadata.root.endsWith(it) } + } + val migrationSettings = migrationMetaData?.let { + legacyBookDao.settingsById(it.id) + } + + if (migrationMetaData != null) { + val legacyChapters = legacyBookDao.chapters() + .filter { + it.bookId == migrationMetaData.id + } + + val legacyBookmarks = legacyBookDao.bookmarksByFiles(legacyChapters.map { it.file }) + legacyBookmarks.forEach { legacyBookmark -> + val legacyChapter = legacyChapters.find { it.file == legacyBookmark.mediaFile } + if (legacyChapter != null) { + val matchingChapter = chapters.find { + val chapterFilePath = it.id.toUri().filePath() ?: return@find false + legacyChapter.file.absolutePath.endsWith(chapterFilePath) + } + if (matchingChapter != null) { + bookmarkDao.addBookmark( + Bookmark( + bookId = id, + addedAt = legacyBookmark.addedAt, + chapterId = matchingChapter.id, + id = Bookmark.Id.random(), + setBySleepTimer = legacyBookmark.setBySleepTimer, + time = legacyBookmark.time, + title = legacyBookmark.title, + ) + ) + } + } + } + } + + val (currentChapter, positionInChapter) = if (migrationSettings != null) { + val currentChapter = chapters.find { + val chapterFilePath = it.id.toUri().filePath() + if (chapterFilePath == null) { + false + } else { + migrationSettings.currentFile.absolutePath.endsWith(chapterFilePath) + } + } + if (currentChapter != null) { + currentChapter.id to migrationSettings.positionInChapter + } else { + chapters.first().id to 0L + } + } else { + chapters.first().id to 0L + } + + BookContent( + id = id, + isActive = true, + addedAt = migrationMetaData?.addedAtMillis?.let(Instant::ofEpochMilli) + ?: Instant.now(), + author = analyzed?.author, + lastPlayedAt = migrationSettings?.lastPlayedAtMillis?.let(Instant::ofEpochMilli) + ?: Instant.EPOCH, + name = migrationMetaData?.name ?: analyzed?.bookName ?: file.bookName(), + playbackSpeed = migrationSettings?.playbackSpeed + ?: 1F, + skipSilence = migrationSettings?.skipSilence + ?: false, + chapters = chapters.map { it.id }, + positionInChapter = positionInChapter, + currentChapter = currentChapter, + cover = migrationSettings?.id?.let { + File(application.filesDir, id.toString()) + .takeIf { it.canRead() } + } + ).also { + validateIntegrity(it, chapters) + } + } + } + + private fun DocumentFile.bookName(): String { + val fileName = name + return if (fileName == null) { + uri.toString() + .removePrefix("/storage/emulated/0/") + .removePrefix("/storage/emulated/") + .removePrefix("/storage/") + .also { + Logger.e("Could not parse fileName from $this. Fallback to $it") + } + } else { + if (isFile) { + fileName.substringBeforeLast(".") + } else { + fileName + } + } + } +} + +internal fun validateIntegrity(content: BookContent, chapters: List) { + // the init block performs integrity validation + Book(content, chapters) +} + +private fun Uri.filePath(): String? { + return pathSegments.lastOrNull() + ?.dropWhile { it != ':' } + ?.removePrefix(":") +} diff --git a/scanner/src/main/kotlin/voice/app/scanner/ChapterParser.kt b/scanner/src/main/kotlin/voice/app/scanner/ChapterParser.kt new file mode 100644 index 0000000000..6ca52f6ee2 --- /dev/null +++ b/scanner/src/main/kotlin/voice/app/scanner/ChapterParser.kt @@ -0,0 +1,50 @@ +package voice.app.scanner + +import androidx.documentfile.provider.DocumentFile +import voice.data.Chapter +import voice.data.repo.ChapterRepo +import java.time.Instant +import javax.inject.Inject + +class ChapterParser +@Inject constructor( + private val chapterRepo: ChapterRepo, + private val mediaAnalyzer: MediaAnalyzer, +) { + + suspend fun parse(documentFile: DocumentFile): List { + val result = mutableListOf() + + suspend fun parseChapters(file: DocumentFile) { + val mimeType = file.type + if ( + file.isFile && + mimeType != null && + (mimeType.startsWith("audio/") || mimeType.startsWith("video/")) + ) { + val id = Chapter.Id(file.uri) + val chapter = chapterRepo.getOrPut(id, Instant.ofEpochMilli(file.lastModified())) { + val metaData = mediaAnalyzer.analyze(file.uri) ?: return@getOrPut null + Chapter( + id = id, + duration = metaData.duration, + fileLastModified = Instant.ofEpochMilli(file.lastModified()), + name = metaData.chapterName, + markData = metaData.chapters + ) + } + if (chapter != null) { + result.add(chapter) + } + } else if (file.isDirectory) { + file.listFiles() + .forEach { + parseChapters(it) + } + } + } + + parseChapters(file = documentFile) + return result.sorted() + } +} diff --git a/scanner/src/main/kotlin/voice/app/scanner/MediaScanner.kt b/scanner/src/main/kotlin/voice/app/scanner/MediaScanner.kt index c1dc1bb0eb..4909f0ca92 100644 --- a/scanner/src/main/kotlin/voice/app/scanner/MediaScanner.kt +++ b/scanner/src/main/kotlin/voice/app/scanner/MediaScanner.kt @@ -2,21 +2,14 @@ package voice.app.scanner import androidx.documentfile.provider.DocumentFile import voice.common.BookId -import voice.data.Book -import voice.data.BookContent -import voice.data.Chapter import voice.data.repo.BookContentRepo -import voice.data.repo.ChapterRepo -import voice.data.toUri -import voice.logging.core.Logger -import java.time.Instant import javax.inject.Inject class MediaScanner @Inject constructor( private val contentRepo: BookContentRepo, - private val chapterRepo: ChapterRepo, - private val mediaAnalyzer: MediaAnalyzer, + private val chapterParser: ChapterParser, + private val bookParser: BookParser, ) { suspend fun scan(folders: List) { @@ -26,32 +19,12 @@ class MediaScanner } private suspend fun scan(file: DocumentFile) { - val chapters = file.parseChapters().sorted() + val chapters = chapterParser.parse(file) if (chapters.isEmpty()) return - val chapterIds = chapters.map { it.id } - val id = BookId(file.uri) - val content = contentRepo.getOrPut(id) { - val analyzed = mediaAnalyzer.analyze(chapterIds.first().toUri()) - val content = BookContent( - id = id, - isActive = true, - addedAt = Instant.now(), - author = analyzed?.author, - lastPlayedAt = Instant.EPOCH, - name = analyzed?.bookName ?: file.bookName(), - playbackSpeed = 1F, - skipSilence = false, - chapters = chapterIds, - positionInChapter = 0L, - currentChapter = chapters.first().id, - cover = null - ) - - validateIntegrity(content, chapters) - content - } + val content = bookParser.getOrPut(chapters, file) + val chapterIds = chapters.map { it.id } val currentChapterGone = content.currentChapter !in chapterIds val currentChapter = if (currentChapterGone) chapterIds.first() else content.currentChapter val positionInChapter = if (currentChapterGone) 0 else content.positionInChapter @@ -66,63 +39,4 @@ class MediaScanner contentRepo.put(updated) } } - - private fun DocumentFile.bookName(): String { - val fileName = name - return if (fileName == null) { - uri.toString() - .removePrefix("/storage/emulated/0/") - .removePrefix("/storage/emulated/") - .removePrefix("/storage/") - .also { - Logger.e("Could not parse fileName from $this. Fallback to $it") - } - } else { - if (isFile) { - fileName.substringBeforeLast(".") - } else { - fileName - } - } - } - - private fun validateIntegrity(content: BookContent, chapters: List) { - // the init block performs integrity validation - Book(content, chapters) - } - - private suspend fun DocumentFile.parseChapters(): List { - val result = mutableListOf() - parseChapters(file = this, result = result) - return result - } - - private suspend fun parseChapters(file: DocumentFile, result: MutableList) { - val mimeType = file.type - if ( - file.isFile && - mimeType != null && - (mimeType.startsWith("audio/") || mimeType.startsWith("video/")) - ) { - val id = Chapter.Id(file.uri) - val chapter = chapterRepo.getOrPut(id, Instant.ofEpochMilli(file.lastModified())) { - val metaData = mediaAnalyzer.analyze(file.uri) ?: return@getOrPut null - Chapter( - id = id, - duration = metaData.duration, - fileLastModified = Instant.ofEpochMilli(file.lastModified()), - name = metaData.chapterName, - markData = metaData.chapters - ) - } - if (chapter != null) { - result.add(chapter) - } - } else if (file.isDirectory) { - file.listFiles() - .forEach { - parseChapters(it, result) - } - } - } } diff --git a/scanner/src/test/kotlin/voice/app/scanner/MediaScannerTest.kt b/scanner/src/test/kotlin/voice/app/scanner/MediaScannerTest.kt index c2fe41fe78..397e695b6d 100644 --- a/scanner/src/test/kotlin/voice/app/scanner/MediaScannerTest.kt +++ b/scanner/src/test/kotlin/voice/app/scanner/MediaScannerTest.kt @@ -131,7 +131,20 @@ class MediaScannerTest { val bookContentRepo = BookContentRepo(db.bookContentDao()) private val chapterRepo = ChapterRepo(db.chapterDao()) private val mediaAnalyzer = mockk() - private val scanner = MediaScanner(bookContentRepo, chapterRepo, mediaAnalyzer) + private val scanner = MediaScanner( + contentRepo = bookContentRepo, + chapterParser = ChapterParser( + chapterRepo = chapterRepo, + mediaAnalyzer = mediaAnalyzer + ), + bookParser = BookParser( + contentRepo = bookContentRepo, + mediaAnalyzer = mediaAnalyzer, + application = ApplicationProvider.getApplicationContext(), + bookmarkDao = db.bookmarkDao(), + legacyBookDao = db.legacyBookDao(), + ) + ) val bookRepo = BookRepository(chapterRepo, bookContentRepo)