Skip to content

Commit 1d940f0

Browse files
committed
feat: support selecting tracks from a release to submit to ListenBrainz
this feature is under the top app bar additional actions when at least one track has been selected closes #1867
1 parent 776cb60 commit 1d940f0

File tree

21 files changed

+899
-155
lines changed

21 files changed

+899
-155
lines changed

data/database/src/commonMain/kotlin/ly/david/musicsearch/data/database/dao/TrackDao.kt

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import app.cash.sqldelight.coroutines.asFlow
55
import app.cash.sqldelight.coroutines.mapToOne
66
import app.cash.sqldelight.paging3.QueryPagingSource
77
import kotlinx.collections.immutable.ImmutableList
8+
import kotlinx.collections.immutable.persistentListOf
89
import kotlinx.coroutines.flow.Flow
910
import kotlinx.coroutines.flow.map
1011
import ly.david.musicsearch.data.database.Database
@@ -14,6 +15,7 @@ import ly.david.musicsearch.data.musicbrainz.models.TrackMusicBrainzModel
1415
import ly.david.musicsearch.shared.domain.alias.BasicAlias
1516
import ly.david.musicsearch.shared.domain.artist.ArtistCreditUiModel
1617
import ly.david.musicsearch.shared.domain.coroutine.CoroutineDispatchers
18+
import ly.david.musicsearch.shared.domain.listen.TrackInfo
1719
import ly.david.musicsearch.shared.domain.listitem.SelectableId
1820
import lydavidmusicsearchdatadatabase.Track
1921

@@ -106,6 +108,25 @@ class TrackDao(
106108
)
107109
}
108110

111+
fun getTracksByReleaseForListenSubmission(releaseId: String): List<TrackInfo> {
112+
return transacter.getTracksByReleaseForListenSubmission(
113+
releaseId = releaseId,
114+
).executeAsList().map {
115+
TrackInfo(
116+
name = it.title,
117+
disambiguation = null,
118+
aliases = persistentListOf(),
119+
recordingId = it.recording_id,
120+
lengthMilliseconds = it.length?.toLong(),
121+
artists = combineToArtistCredits(
122+
names = it.credit_names,
123+
ids = it.credit_artist_ids,
124+
joinPhrases = it.credit_join_phrases,
125+
),
126+
)
127+
}
128+
}
129+
109130
fun getAllTrackIdsByRelease(releaseId: String): List<SelectableId> {
110131
return transacter.getAllTrackIdsByRelease(
111132
releaseId = releaseId,

data/database/src/commonMain/sqldelight/ly.david.musicsearch.data.database/track.sq

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,37 @@ GROUP BY track.id
131131
ORDER BY medium.position, track.position
132132
LIMIT :limit OFFSET :offset;
133133

134+
getTracksByReleaseForListenSubmission:
135+
SELECT
136+
track.recording_id,
137+
track.title,
138+
track.length,
139+
track_credits.credit_names,
140+
track_credits.credit_artist_ids,
141+
track_credits.credit_join_phrases
142+
FROM track
143+
INNER JOIN medium ON track.medium_id = medium.id
144+
INNER JOIN `release` ON medium.release_id = `release`.id
145+
LEFT JOIN (
146+
SELECT
147+
ace.entity_id AS track_id,
148+
GROUP_CONCAT(acn.name, CHAR(9)) AS credit_names,
149+
GROUP_CONCAT(acn.artist_id, CHAR(9)) AS credit_artist_ids,
150+
GROUP_CONCAT(acn.join_phrase, CHAR(9)) AS credit_join_phrases
151+
FROM artist_credit_entity ace
152+
INNER JOIN artist_credit_name acn ON acn.artist_credit_id = ace.artist_credit_id
153+
WHERE ace.entity_id IN (
154+
SELECT t.id
155+
FROM track t
156+
INNER JOIN medium m ON t.medium_id = m.id
157+
WHERE m.release_id = :releaseId
158+
)
159+
GROUP BY ace.entity_id
160+
) AS track_credits ON track_credits.track_id = track.id
161+
WHERE `release`.id = :releaseId
162+
GROUP BY track.id
163+
ORDER BY medium.position, track.position;
164+
134165
getAllTrackIdsByRelease:
135166
SELECT
136167
track.id,

data/listenbrainz/src/commonMain/kotlin/ly/david/musicsearch/data/listenbrainz/api/ListenBrainzApi.kt

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ interface ListenBrainzApi {
3333
username: String,
3434
minTimestamp: Long? = null,
3535
maxTimestamp: Long? = null,
36-
count: Int = LISTENS_COUNT,
36+
count: Int = DEFAULT_GET_LISTENS_COUNT,
3737
): GetListensResponse
3838

3939
suspend fun submitManualMapping(
@@ -96,7 +96,15 @@ interface ListenBrainzApi {
9696
}
9797
}
9898

99-
private const val LISTENS_COUNT = 100
99+
const val DEFAULT_GET_LISTENS_COUNT = 100
100+
101+
/**
102+
* https://listenbrainz.readthedocs.io/en/latest/users/api/core.html#listenbrainz.webserver.views.api_tools.MAX_ITEMS_PER_GET
103+
* and
104+
* https://listenbrainz.readthedocs.io/en/latest/users/api/core.html#listenbrainz.webserver.views.api_tools.MAX_LISTENS_PER_REQUEST
105+
*/
106+
const val MAX_GET_POST_LISTENS_COUNT = 1000
107+
100108
private const val CONTENT_TYPE = "Content-Type"
101109
private const val APPLICATION_JSON = "application/json"
102110

data/repository/src/commonMain/kotlin/ly/david/musicsearch/data/repository/di/RepositoryDataModule.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import ly.david.musicsearch.data.repository.list.ObserveCollectedCountImpl
2525
import ly.david.musicsearch.data.repository.list.ObserveLocalCountImpl
2626
import ly.david.musicsearch.data.repository.list.ObserveTrackCountImpl
2727
import ly.david.musicsearch.data.repository.list.ObserveVisitedCountImpl
28+
import ly.david.musicsearch.data.repository.listen.GetTracksByReleaseForListenSubmissionImpl
2829
import ly.david.musicsearch.data.repository.listen.ListensListRepositoryImpl
2930
import ly.david.musicsearch.data.repository.metadata.MetadataRepositoryImpl
3031
import ly.david.musicsearch.data.repository.place.PlaceRepositoryImpl
@@ -67,6 +68,7 @@ import ly.david.musicsearch.shared.domain.list.ObserveCollectedCount
6768
import ly.david.musicsearch.shared.domain.list.ObserveLocalCount
6869
import ly.david.musicsearch.shared.domain.list.ObserveTrackCount
6970
import ly.david.musicsearch.shared.domain.list.ObserveVisitedCount
71+
import ly.david.musicsearch.shared.domain.listen.GetTracksByReleaseForListenSubmission
7072
import ly.david.musicsearch.shared.domain.listen.ListensListRepository
7173
import ly.david.musicsearch.shared.domain.metadata.MetadataRepository
7274
import ly.david.musicsearch.shared.domain.nowplaying.NowPlayingHistoryRepository
@@ -137,4 +139,5 @@ val repositoryDataModule = module {
137139
singleOf(::ObserveVisitedCountImpl) bind ObserveVisitedCount::class
138140
singleOf(::ListensListRepositoryImpl) bind ListensListRepository::class
139141
singleOf(::GetTrackIdsByReleaseImpl) bind GetTrackIdsByRelease::class
142+
singleOf(::GetTracksByReleaseForListenSubmissionImpl) bind GetTracksByReleaseForListenSubmission::class
140143
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package ly.david.musicsearch.data.repository.listen
2+
3+
import ly.david.musicsearch.data.database.dao.TrackDao
4+
import ly.david.musicsearch.shared.domain.listen.GetTracksByReleaseForListenSubmission
5+
import ly.david.musicsearch.shared.domain.listen.TrackInfo
6+
7+
class GetTracksByReleaseForListenSubmissionImpl(
8+
private val trackDao: TrackDao,
9+
) : GetTracksByReleaseForListenSubmission {
10+
override fun invoke(releaseId: String): List<TrackInfo> {
11+
return trackDao.getTracksByReleaseForListenSubmission(
12+
releaseId = releaseId,
13+
)
14+
}
15+
}

data/repository/src/commonMain/kotlin/ly/david/musicsearch/data/repository/listen/ListensListRepositoryImpl.kt

Lines changed: 44 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,17 @@ import androidx.paging.TerminalSeparatorType
77
import androidx.paging.cachedIn
88
import androidx.paging.insertSeparators
99
import kotlinx.coroutines.CoroutineScope
10+
import kotlinx.coroutines.delay
1011
import kotlinx.coroutines.flow.Flow
1112
import kotlinx.coroutines.flow.distinctUntilChanged
1213
import kotlinx.coroutines.flow.emptyFlow
1314
import kotlinx.coroutines.flow.map
15+
import ly.david.musicsearch.data.listenbrainz.api.DEFAULT_GET_LISTENS_COUNT
1416
import ly.david.musicsearch.data.listenbrainz.api.ListenBrainzApi
17+
import ly.david.musicsearch.data.listenbrainz.api.MAX_GET_POST_LISTENS_COUNT
1518
import ly.david.musicsearch.data.listenbrainz.api.asListOfListens
1619
import ly.david.musicsearch.shared.domain.Identifiable
20+
import ly.david.musicsearch.shared.domain.MS_IN_SECOND
1721
import ly.david.musicsearch.shared.domain.artist.ArtistCreditUiModel
1822
import ly.david.musicsearch.shared.domain.common.getFullDateFormatted
1923
import ly.david.musicsearch.shared.domain.common.toUUID
@@ -186,20 +190,17 @@ class ListensListRepositoryImpl(
186190
listenSubmissions: List<ListenSubmission>,
187191
): Feedback<SubmitListenFeedback> {
188192
return try {
189-
listenBrainzApi.submitListens(
190-
listenSubmissions = listenSubmissions,
193+
val clampedTotalCount = listenSubmissions.size.coerceIn(
194+
minimumValue = DEFAULT_GET_LISTENS_COUNT,
195+
maximumValue = MAX_GET_POST_LISTENS_COUNT,
191196
)
192-
val listensResponse = listenBrainzApi.getListensByUser(
193-
username = username,
194-
maxTimestamp = listenSubmissions.maxByOrNull { it.listenedAtS }?.listenedAtS?.plus(1),
195-
// Count should be at least the size of the number of listens, but ideally larger, as
196-
// the user may have submitted other listens in the past through other methods.
197-
// In one extreme case, the user may have 100 other listens at the same time,
198-
// then it's possible the recently submitted listen will not be fetched.
199-
// I don't think fetching until we found our submitted listens to be worth the overhead
200-
// to handle this extreme case.
201-
)
202-
listenDao.insert(listens = listensResponse.asListOfListens())
197+
198+
listenSubmissions.chunked(clampedTotalCount).forEach { chunkedListens ->
199+
submitListensInChunksThenFetch(
200+
chunkedListens = chunkedListens,
201+
username = username,
202+
)
203+
}
203204

204205
Feedback.Success(
205206
data = SubmitListenFeedback.SubmittedListens,
@@ -218,6 +219,36 @@ class ListensListRepositoryImpl(
218219
}
219220
}
220221

222+
private suspend fun submitListensInChunksThenFetch(
223+
chunkedListens: List<ListenSubmission>,
224+
username: String,
225+
) {
226+
listenBrainzApi.submitListens(
227+
listenSubmissions = chunkedListens,
228+
)
229+
230+
// Need to wait a bit for the server to process the listens.
231+
// This isn't enough time for larger releases, but we don't want to make the user wait too long.
232+
delay(MS_IN_SECOND.toLong())
233+
234+
// Count should be at least the size of the number of listens, but ideally larger, as
235+
// the user may have submitted other listens in the past through other methods.
236+
// In one extreme case, the user may have 100 other listens at the same time,
237+
// then it's possible the recently submitted listen will not be fetched.
238+
// I don't think fetching until we find our submitted listens to be worth the overhead
239+
// to handle this extreme case.
240+
val clampedChunkCount = chunkedListens.size.coerceIn(
241+
minimumValue = DEFAULT_GET_LISTENS_COUNT,
242+
maximumValue = MAX_GET_POST_LISTENS_COUNT,
243+
)
244+
val listensResponse = listenBrainzApi.getListensByUser(
245+
username = username,
246+
maxTimestamp = chunkedListens.maxByOrNull { it.listenedAtS }?.listenedAtS?.plus(1),
247+
count = clampedChunkCount,
248+
)
249+
listenDao.insert(listens = listensResponse.asListOfListens())
250+
}
251+
221252
override suspend fun refreshMapping(
222253
recordingMessyBrainzId: String,
223254
): Feedback<ListensListFeedback> {

shared/domain/src/commonMain/kotlin/ly/david/musicsearch/shared/domain/common/IntExt.kt renamed to shared/domain/src/commonMain/kotlin/ly/david/musicsearch/shared/domain/common/ToDisplayTime.kt

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,20 @@ private const val SINGLE_DIGIT_THRESHOLD = 10
88

99
const val UNKNOWN_TIME = "?:??"
1010

11+
fun Int?.toDisplayTime(): String {
12+
return this?.toLong().toDisplayTime()
13+
}
14+
1115
/**
1216
* Given time in milliseconds, returns a human-readable time string.
1317
*/
14-
fun Int?.toDisplayTime(): String {
18+
fun Long?.toDisplayTime(): String {
1519
if (this == null || this < 0) return UNKNOWN_TIME
1620

1721
val timeWithoutMs = this / MS_IN_SECOND
1822
var minutes = timeWithoutMs / SECONDS_IN_MINUTE
1923

20-
var hours = 0
24+
var hours = 0L
2125
if (minutes >= MINUTES_IN_HOUR) {
2226
hours = minutes / MINUTES_IN_HOUR
2327
minutes %= hours
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package ly.david.musicsearch.shared.domain.listen
2+
3+
interface GetTracksByReleaseForListenSubmission {
4+
operator fun invoke(releaseId: String): List<TrackInfo>
5+
}
Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,24 @@
11
package ly.david.musicsearch.shared.domain.listen
22

33
import kotlinx.collections.immutable.ImmutableList
4-
import ly.david.musicsearch.shared.domain.NameWithDisambiguationAndAliases
5-
import ly.david.musicsearch.shared.domain.alias.BasicAlias
64
import ly.david.musicsearch.shared.domain.artist.ArtistCreditUiModel
75
import ly.david.musicsearch.shared.domain.parcelize.CommonParcelable
86
import ly.david.musicsearch.shared.domain.parcelize.Parcelize
97

108
sealed interface SubmitListenType : CommonParcelable {
119
@Parcelize
1210
data class Track(
13-
override val name: String,
14-
override val disambiguation: String?,
15-
override val aliases: ImmutableList<BasicAlias>,
16-
val recordingId: String,
17-
val lengthMilliseconds: Int?,
18-
val artists: ImmutableList<ArtistCreditUiModel>,
11+
val info: TrackInfo,
1912
// optional because we can submit a listen from Recording screen, where the release would be ambiguous
2013
val releaseName: String?,
2114
val releaseId: String?,
22-
) : SubmitListenType, NameWithDisambiguationAndAliases {
23-
override fun withAliases(aliases: ImmutableList<BasicAlias>): NameWithDisambiguationAndAliases {
24-
return copy(aliases = aliases)
25-
}
26-
}
15+
) : SubmitListenType
2716

28-
// data class Album(
29-
//
30-
// ): SubmitType
17+
@Parcelize
18+
data class Album(
19+
val recordingIds: ImmutableList<String>,
20+
val releaseName: String,
21+
val releaseId: String,
22+
val releaseArtists: ImmutableList<ArtistCreditUiModel>,
23+
) : SubmitListenType
3124
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package ly.david.musicsearch.shared.domain.listen
2+
3+
import kotlinx.collections.immutable.ImmutableList
4+
import ly.david.musicsearch.shared.domain.MS_IN_SECOND
5+
import ly.david.musicsearch.shared.domain.NameWithDisambiguationAndAliases
6+
import ly.david.musicsearch.shared.domain.alias.BasicAlias
7+
import ly.david.musicsearch.shared.domain.artist.ArtistCreditUiModel
8+
import ly.david.musicsearch.shared.domain.parcelize.CommonParcelable
9+
import ly.david.musicsearch.shared.domain.parcelize.Parcelize
10+
11+
private const val MINIMUM_TRACK_LENGTH_SECONDS = 60L
12+
13+
@Parcelize
14+
data class TrackInfo(
15+
override val name: String,
16+
override val disambiguation: String?,
17+
override val aliases: ImmutableList<BasicAlias>,
18+
val recordingId: String,
19+
val lengthMilliseconds: Long?,
20+
val artists: ImmutableList<ArtistCreditUiModel>,
21+
) : CommonParcelable, NameWithDisambiguationAndAliases {
22+
override fun withAliases(aliases: ImmutableList<BasicAlias>): NameWithDisambiguationAndAliases =
23+
copy(aliases = aliases)
24+
25+
val nonZeroLengthMilliseconds: Long
26+
get() = lengthMilliseconds ?: (MINIMUM_TRACK_LENGTH_SECONDS * MS_IN_SECOND)
27+
}

0 commit comments

Comments
 (0)