Skip to content

Commit

Permalink
Attempt to recover the old book positions.
Browse files Browse the repository at this point in the history
  • Loading branch information
PaulWoitaschek committed Aug 1, 2022
1 parent 7b4aa3c commit 4317014
Show file tree
Hide file tree
Showing 5 changed files with 224 additions and 92 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -19,12 +21,18 @@ interface LegacyBookDao {
@Query("SELECT * FROM bookSettings")
suspend fun settings(): List<LegacyBookSettings>

@Query("SELECT * FROM bookSettings WHERE id = :id")
suspend fun settingsById(id: UUID): LegacyBookSettings?

@Query("SELECT * FROM chapters")
suspend fun chapters(): List<LegacyChapter>

@Query("SELECT * FROM bookmark")
suspend fun bookmarks(): List<LegacyBookmark>

@Query("SELECT * FROM bookmark WHERE file IN(:chapters)")
suspend fun bookmarksByFiles(chapters: List<@JvmSuppressWildcards File>): List<LegacyBookmark>

@Query("DELETE FROM bookmark")
suspend fun deleteBookmarks()

Expand Down
147 changes: 147 additions & 0 deletions scanner/src/main/kotlin/voice/app/scanner/BookParser.kt
Original file line number Diff line number Diff line change
@@ -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<Chapter>, 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<Chapter>) {
// the init block performs integrity validation
Book(content, chapters)
}

private fun Uri.filePath(): String? {
return pathSegments.lastOrNull()
?.dropWhile { it != ':' }
?.removePrefix(":")
}
50 changes: 50 additions & 0 deletions scanner/src/main/kotlin/voice/app/scanner/ChapterParser.kt
Original file line number Diff line number Diff line change
@@ -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<Chapter> {
val result = mutableListOf<Chapter>()

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()
}
}
96 changes: 5 additions & 91 deletions scanner/src/main/kotlin/voice/app/scanner/MediaScanner.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<DocumentFile>) {
Expand All @@ -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
Expand All @@ -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<Chapter>) {
// the init block performs integrity validation
Book(content, chapters)
}

private suspend fun DocumentFile.parseChapters(): List<Chapter> {
val result = mutableListOf<Chapter>()
parseChapters(file = this, result = result)
return result
}

private suspend fun parseChapters(file: DocumentFile, result: MutableList<Chapter>) {
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)
}
}
}
}
15 changes: 14 additions & 1 deletion scanner/src/test/kotlin/voice/app/scanner/MediaScannerTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,20 @@ class MediaScannerTest {
val bookContentRepo = BookContentRepo(db.bookContentDao())
private val chapterRepo = ChapterRepo(db.chapterDao())
private val mediaAnalyzer = mockk<MediaAnalyzer>()
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)

Expand Down

0 comments on commit 4317014

Please sign in to comment.