Skip to content

Commit 6905b51

Browse files
committed
musikr: introduce streaming file tree
Probably the best way I can go about this.
1 parent e3fe7c4 commit 6905b51

5 files changed

Lines changed: 117 additions & 43 deletions

File tree

musikr/src/main/java/org/oxycblt/musikr/covers/fs/FSCovers.kt

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ import android.os.ParcelFileDescriptor
2525
import androidx.core.net.toUri
2626
import java.io.InputStream
2727
import kotlinx.coroutines.Dispatchers
28+
import kotlinx.coroutines.flow.filterIsInstance
29+
import kotlinx.coroutines.flow.map
30+
import kotlinx.coroutines.flow.toList
2831
import kotlinx.coroutines.withContext
2932
import org.oxycblt.musikr.covers.Cover
3033
import org.oxycblt.musikr.covers.CoverResult
@@ -96,11 +99,12 @@ class MutableFSCovers(private val context: Context) : MutableCovers<FDCover> {
9699
override suspend fun create(file: DeviceFile, metadata: Metadata): CoverResult<FDCover> {
97100
// Since DeviceFiles is a streaming API, we have to wait for the current recursive
98101
// query to finally finish to be able to have a complete list of siblings to search for.
99-
val parent = file.parent.await()
102+
val parent = file.parent
100103
val bestCover =
101-
parent.children
104+
parent.children.flow()
102105
.filterIsInstance<DeviceFile>()
103106
.map { it to coverArtScore(it) }
107+
.toList()
104108
.maxBy { it.second }
105109
if (bestCover.second > 0) {
106110
return CoverResult.Hit(FolderCoverImpl(context, bestCover.first.uri))
@@ -128,7 +132,7 @@ class MutableFSCovers(private val context: Context) : MutableCovers<FDCover> {
128132
// See if the name contains any of the preferred cover names. This helps weed out
129133
// images that are not actually cover art and are just there.,
130134
var score =
131-
(preferredCoverNames + requireNotNull(file.parent.await().path.name))
135+
(preferredCoverNames + requireNotNull(file.parent.path.name))
132136
.withIndex()
133137
.filter { name.contains(it.value, ignoreCase = true) }
134138
.sumOf { it.index + 1 }

musikr/src/main/java/org/oxycblt/musikr/fs/device/DeviceFS.kt

Lines changed: 56 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -15,27 +15,31 @@
1515
* You should have received a copy of the GNU General Public License
1616
* along with this program. If not, see <https://www.gnu.org/licenses/>.
1717
*/
18-
18+
1919
package org.oxycblt.musikr.fs.device
2020

2121
import android.content.ContentResolver
2222
import android.content.Context
2323
import android.net.Uri
2424
import android.provider.DocumentsContract
2525
import kotlinx.coroutines.CompletableDeferred
26+
import kotlinx.coroutines.CoroutineScope
2627
import kotlinx.coroutines.Deferred
28+
import kotlinx.coroutines.Dispatchers
2729
import kotlinx.coroutines.ExperimentalCoroutinesApi
30+
import kotlinx.coroutines.coroutineScope
2831
import kotlinx.coroutines.flow.Flow
2932
import kotlinx.coroutines.flow.asFlow
3033
import kotlinx.coroutines.flow.emitAll
3134
import kotlinx.coroutines.flow.flatMapMerge
3235
import kotlinx.coroutines.flow.flattenMerge
3336
import kotlinx.coroutines.flow.flow
37+
import kotlinx.coroutines.flow.map
3438
import org.oxycblt.musikr.fs.MusicLocation
3539
import org.oxycblt.musikr.fs.Path
3640

3741
internal interface DeviceFS {
38-
fun explore(locations: Flow<MusicLocation>): Flow<DeviceFile>
42+
fun explore(locations: Flow<MusicLocation>): Flow<DeviceDirectory>
3943

4044
companion object {
4145
fun from(context: Context, withHidden: Boolean): DeviceFS =
@@ -48,27 +52,39 @@ private class DeviceFSImpl(
4852
private val contentResolver: ContentResolver,
4953
private val withHidden: Boolean
5054
) : DeviceFS {
51-
override fun explore(locations: Flow<MusicLocation>): Flow<DeviceFile> =
52-
locations.flatMapMerge { location ->
53-
exploreDirectoryImpl(
54-
location.uri,
55-
DocumentsContract.getTreeDocumentId(location.uri),
56-
location.path,
57-
null)
55+
override fun explore(locations: Flow<MusicLocation>): Flow<DeviceDirectory> =
56+
locations.map { location ->
57+
coroutineScope {
58+
DeviceDirectoryImpl(
59+
location.uri,
60+
DocumentsContract.getTreeDocumentId(location.uri),
61+
location.path,
62+
null,
63+
coroutineScope = CoroutineScope(Dispatchers.IO),
64+
contentResolver = contentResolver,
65+
withHidden = withHidden
66+
)
67+
}
5868
}
69+
}
5970

60-
private fun exploreDirectoryImpl(
61-
rootUri: Uri,
62-
treeDocumentId: String,
63-
relativePath: Path,
64-
parent: Deferred<DeviceDirectory>?
65-
): Flow<DeviceFile> = flow {
66-
// Make a kotlin future
67-
val uri = DocumentsContract.buildChildDocumentsUriUsingTree(rootUri, treeDocumentId)
68-
val directoryDeferred = CompletableDeferred<DeviceDirectory>()
69-
val recursive = mutableListOf<Flow<DeviceFile>>()
70-
val children = mutableListOf<DeviceFSEntry>()
71-
contentResolver.useQuery(uri, PROJECTION) { cursor ->
71+
private class DeviceDirectoryImpl(
72+
rootUri: Uri,
73+
treeDocumentId: String,
74+
override val path: Path,
75+
override val parent: DeviceDirectoryImpl?,
76+
coroutineScope: CoroutineScope,
77+
contentResolver: ContentResolver,
78+
withHidden: Boolean
79+
) : DeviceDirectory {
80+
override val uri: Uri = DocumentsContract.buildDocumentUriUsingTree(rootUri, treeDocumentId)
81+
override val children = FiniteHotFlow<DeviceFSEntry>(coroutineScope) { emit ->
82+
contentResolver.useQuery(
83+
DocumentsContract.buildChildDocumentsUriUsingTree(
84+
rootUri,
85+
treeDocumentId
86+
), PROJECTION
87+
) { cursor ->
7288
val childUriIndex =
7389
cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DOCUMENT_ID)
7490
val displayNameIndex =
@@ -88,13 +104,22 @@ private class DeviceFSImpl(
88104
continue
89105
}
90106

91-
val newPath = relativePath.file(displayName)
107+
val newPath = path.file(displayName)
92108
val mimeType = cursor.getString(mimeTypeIndex)
93109
val lastModified = cursor.getLong(lastModifiedIndex)
94110

95111
if (mimeType == DocumentsContract.Document.MIME_TYPE_DIR) {
96-
recursive.add(
97-
exploreDirectoryImpl(rootUri, childId, newPath, directoryDeferred))
112+
emit(
113+
DeviceDirectoryImpl(
114+
rootUri,
115+
childId,
116+
path = newPath,
117+
parent = this,
118+
coroutineScope,
119+
contentResolver,
120+
withHidden
121+
)
122+
)
98123
} else {
99124
val size = cursor.getLong(sizeIndex)
100125
val childUri = DocumentsContract.buildDocumentUriUsingTree(rootUri, childId)
@@ -105,23 +130,24 @@ private class DeviceFSImpl(
105130
path = newPath,
106131
size = size,
107132
modifiedMs = lastModified,
108-
parent = directoryDeferred)
109-
children.add(file)
133+
parent = this
134+
)
110135
emit(file)
111136
}
112137
}
113-
directoryDeferred.complete(DeviceDirectory(uri, relativePath, parent, children))
114-
emitAll(recursive.asFlow().flattenMerge())
115138
}
116139
}
117140

118141
private companion object {
119-
val PROJECTION =
142+
private val PROJECTION =
120143
arrayOf(
121144
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
122145
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
123146
DocumentsContract.Document.COLUMN_MIME_TYPE,
124147
DocumentsContract.Document.COLUMN_SIZE,
125-
DocumentsContract.Document.COLUMN_LAST_MODIFIED)
148+
DocumentsContract.Document.COLUMN_LAST_MODIFIED
149+
)
126150
}
127151
}
152+
153+

musikr/src/main/java/org/oxycblt/musikr/fs/device/DeviceFSEntries.kt

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,25 +20,35 @@ package org.oxycblt.musikr.fs.device
2020

2121
import android.net.Uri
2222
import kotlinx.coroutines.Deferred
23+
import kotlinx.coroutines.ExperimentalCoroutinesApi
24+
import kotlinx.coroutines.flow.Flow
25+
import kotlinx.coroutines.flow.flatMapMerge
26+
import kotlinx.coroutines.flow.flowOf
2327
import org.oxycblt.musikr.fs.Path
2428

2529
sealed interface DeviceFSEntry {
2630
val uri: Uri
2731
val path: Path
2832
}
2933

30-
data class DeviceDirectory(
31-
override val uri: Uri,
32-
override val path: Path,
33-
val parent: Deferred<DeviceDirectory>?,
34-
var children: List<DeviceFSEntry>
35-
) : DeviceFSEntry
34+
interface DeviceDirectory : DeviceFSEntry {
35+
val parent: DeviceDirectory?
36+
val children: FiniteHotFlow<DeviceFSEntry>
37+
}
3638

3739
data class DeviceFile(
3840
override val uri: Uri,
3941
override val path: Path,
4042
val modifiedMs: Long,
4143
val mimeType: String,
4244
val size: Long,
43-
val parent: Deferred<DeviceDirectory>
45+
val parent: DeviceDirectory
4446
) : DeviceFSEntry
47+
48+
@OptIn(ExperimentalCoroutinesApi::class)
49+
fun DeviceDirectory.flatten(): Flow<DeviceFile> = children.flow().flatMapMerge {
50+
when (it) {
51+
is DeviceDirectory -> it.flatten()
52+
is DeviceFile -> flowOf(it)
53+
}
54+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package org.oxycblt.musikr.fs.device
2+
3+
import kotlinx.coroutines.CoroutineScope
4+
import kotlinx.coroutines.Dispatchers
5+
import kotlinx.coroutines.flow.FlowCollector
6+
import kotlinx.coroutines.flow.SharingStarted
7+
import kotlinx.coroutines.flow.flow
8+
import kotlinx.coroutines.flow.map
9+
import kotlinx.coroutines.flow.shareIn
10+
import kotlinx.coroutines.flow.takeWhile
11+
12+
class FiniteHotFlow<T>(scope: CoroutineScope, emitter: suspend (emit: suspend (T) -> Unit) -> Unit) {
13+
private sealed interface HotObject<T> {
14+
data class More<T>(val value: T) : HotObject<T>
15+
data object Done : HotObject<Nothing>
16+
}
17+
18+
private val sharedFlow = flow {
19+
emitter {
20+
emit(HotObject.More(it))
21+
}
22+
emit(HotObject.Done)
23+
}.shareIn(scope, SharingStarted.Lazily, replay = Int.MAX_VALUE)
24+
25+
@Suppress("UNCHECKED_CAST")
26+
fun flow() = flow {
27+
sharedFlow.takeWhile { it is HotObject.More<*> }.collect {
28+
emit((it as HotObject.More<*>).value as T)
29+
}
30+
}
31+
}

musikr/src/main/java/org/oxycblt/musikr/pipeline/ExploreStep.kt

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,14 @@ package org.oxycblt.musikr.pipeline
2020

2121
import android.content.Context
2222
import kotlinx.coroutines.Dispatchers
23+
import kotlinx.coroutines.ExperimentalCoroutinesApi
2324
import kotlinx.coroutines.channels.Channel
2425
import kotlinx.coroutines.flow.Flow
2526
import kotlinx.coroutines.flow.asFlow
2627
import kotlinx.coroutines.flow.buffer
2728
import kotlinx.coroutines.flow.emitAll
2829
import kotlinx.coroutines.flow.filter
30+
import kotlinx.coroutines.flow.flatMapMerge
2931
import kotlinx.coroutines.flow.flow
3032
import kotlinx.coroutines.flow.flowOn
3133
import kotlinx.coroutines.flow.map
@@ -40,6 +42,7 @@ import org.oxycblt.musikr.covers.CoverResult
4042
import org.oxycblt.musikr.covers.Covers
4143
import org.oxycblt.musikr.fs.MusicLocation
4244
import org.oxycblt.musikr.fs.device.DeviceFS
45+
import org.oxycblt.musikr.fs.device.flatten
4346
import org.oxycblt.musikr.playlist.db.StoredPlaylists
4447
import org.oxycblt.musikr.playlist.m3u.M3U
4548

@@ -62,13 +65,13 @@ private class ExploreStepImpl(
6265
private val covers: Covers<out Cover>,
6366
private val storedPlaylists: StoredPlaylists
6467
) : ExploreStep {
68+
@OptIn(ExperimentalCoroutinesApi::class)
6569
override fun explore(locations: List<MusicLocation>): Flow<Explored> {
6670
val addingMs = System.currentTimeMillis()
6771
return merge(
6872
deviceFS
69-
.explore(
70-
locations.asFlow(),
71-
)
73+
.explore(locations.asFlow())
74+
.flatMapMerge { it.flatten() }
7275
.filter { it.mimeType.startsWith("audio/") || it.mimeType == M3U.MIME_TYPE }
7376
.distributedMap(n = 8, on = Dispatchers.IO, buffer = Channel.UNLIMITED) { file ->
7477
when (val cacheResult = cache.read(file)) {

0 commit comments

Comments
 (0)