musikr: decouple name from auxio

This commit is contained in:
Alexander Capehart 2024-12-14 13:41:38 -07:00
parent de1c091517
commit c5cd404393
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
45 changed files with 330 additions and 241 deletions

View file

@ -31,6 +31,7 @@ import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.menu.Menu
import org.oxycblt.auxio.music.PlaylistDecision
import org.oxycblt.auxio.music.PlaylistMessage
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.playback.PlaybackDecision
import org.oxycblt.auxio.playback.formatDurationMs

View file

@ -31,6 +31,7 @@ import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.menu.Menu
import org.oxycblt.auxio.music.PlaylistDecision
import org.oxycblt.auxio.music.PlaylistMessage
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.playback.PlaybackDecision
import org.oxycblt.auxio.util.collect

View file

@ -31,6 +31,7 @@ import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.menu.Menu
import org.oxycblt.auxio.music.PlaylistDecision
import org.oxycblt.auxio.music.PlaylistMessage
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.playback.PlaybackDecision
import org.oxycblt.auxio.util.collect
import org.oxycblt.auxio.util.collectImmediately

View file

@ -37,6 +37,7 @@ import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.menu.Menu
import org.oxycblt.auxio.music.PlaylistDecision
import org.oxycblt.auxio.music.PlaylistMessage
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.playback.PlaybackDecision
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.ui.DialogAwareNavigationListener

View file

@ -32,6 +32,7 @@ import org.oxycblt.auxio.databinding.DialogSongDetailBinding
import org.oxycblt.auxio.detail.list.SongProperty
import org.oxycblt.auxio.detail.list.SongPropertyAdapter
import org.oxycblt.auxio.list.adapter.UpdateInstructions
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.playback.replaygain.formatDb

View file

@ -25,6 +25,7 @@ import org.oxycblt.auxio.list.ClickableListListener
import org.oxycblt.auxio.list.adapter.FlexibleListAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.list.recycler.DialogRecyclerView
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.inflater
import org.oxycblt.musikr.Artist

View file

@ -35,6 +35,7 @@ import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getAttrColorCompat

View file

@ -29,6 +29,7 @@ import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.inflater
import org.oxycblt.musikr.Album

View file

@ -40,6 +40,7 @@ import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.list.recycler.MaterialDragCallback
import org.oxycblt.auxio.list.recycler.SongViewHolder
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getAttrColorCompat

View file

@ -105,10 +105,10 @@ class AlbumListFragment :
// Change how we display the popup depending on the current sort mode.
return when (homeModel.albumSort.mode) {
// By Name -> Use Name
is Sort.Mode.ByName -> album.name.thumb
is Sort.Mode.ByName -> album.name.thumb()
// By Artist -> Use name of first artist
is Sort.Mode.ByArtist -> album.artists[0].name.thumb
is Sort.Mode.ByArtist -> album.artists[0].name.thumb()
// Date -> Use minimum date (Maximum dates are not sorted by, so showing them is odd)
is Sort.Mode.ByDate -> album.dates?.run { min.resolve(requireContext()) }

View file

@ -100,7 +100,7 @@ class ArtistListFragment :
// Change how we display the popup depending on the current sort mode.
return when (homeModel.artistSort.mode) {
// By Name -> Use Name
is Sort.Mode.ByName -> artist.name.thumb
is Sort.Mode.ByName -> artist.name.thumb()
// Duration -> Use formatted duration
is Sort.Mode.ByDuration -> artist.durationMs?.formatDurationMs(false)

View file

@ -99,7 +99,7 @@ class GenreListFragment :
// Change how we display the popup depending on the current sort mode.
return when (homeModel.genreSort.mode) {
// By Name -> Use Name
is Sort.Mode.ByName -> genre.name.thumb
is Sort.Mode.ByName -> genre.name.thumb()
// Duration -> Use formatted duration
is Sort.Mode.ByDuration -> genre.durationMs.formatDurationMs(false)

View file

@ -0,0 +1,32 @@
/*
* Copyright (c) 2024 Auxio Project
* ListUtil.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.home.list
import androidx.core.text.isDigitsOnly
import org.oxycblt.musikr.tag.Name
fun Name.thumb() =
when (this) {
is Name.Known ->
tokens.firstOrNull()?.let {
val value = it.collationKey.sourceString
if (value.isDigitsOnly()) "#" else value
}
is Name.Unknown -> "?"
}

View file

@ -97,7 +97,7 @@ class PlaylistListFragment :
// Change how we display the popup depending on the current sort mode.
return when (homeModel.playlistSort.mode) {
// By Name -> Use Name
is Sort.Mode.ByName -> playlist.name.thumb
is Sort.Mode.ByName -> playlist.name.thumb()
// Duration -> Use formatted duration
is Sort.Mode.ByDuration -> playlist.durationMs.formatDurationMs(false)

View file

@ -105,13 +105,13 @@ class SongListFragment :
// based off the names of the parent objects and not the child objects.
return when (homeModel.songSort.mode) {
// Name -> Use name
is Sort.Mode.ByName -> song.name.thumb
is Sort.Mode.ByName -> song.name.thumb()
// Artist -> Use name of first artist
is Sort.Mode.ByArtist -> song.album.artists[0].name.thumb
is Sort.Mode.ByArtist -> song.album.artists[0].name.thumb()
// Album -> Use Album Name
is Sort.Mode.ByAlbum -> song.album.name.thumb
is Sort.Mode.ByAlbum -> song.album.name.thumb()
// Year -> Use Full Year
is Sort.Mode.ByDate -> song.album.dates?.resolveDate(requireContext())

View file

@ -28,6 +28,7 @@ import org.oxycblt.auxio.databinding.DialogMenuBinding
import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.list.ListViewModel
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs

View file

@ -33,6 +33,7 @@ import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.music.areNamesTheSame
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getPlural

View file

@ -39,7 +39,7 @@ import org.oxycblt.musikr.Storage
import org.oxycblt.musikr.cache.Cache
import org.oxycblt.musikr.cache.CacheDatabase
import org.oxycblt.musikr.cover.StoredCovers
import org.oxycblt.musikr.tag.Name
import org.oxycblt.musikr.tag.interpret.Naming
import org.oxycblt.musikr.tag.interpret.Separators
import timber.log.Timber as L
@ -353,9 +353,9 @@ constructor(
val separators = Separators.from(musicSettings.separators)
val nameFactory =
if (musicSettings.intelligentSorting) {
Name.Known.IntelligentFactory
Naming.intelligent()
} else {
Name.Known.SimpleFactory
Naming.simple()
}
val locations = musicSettings.musicLocations

View file

@ -20,8 +20,22 @@ package org.oxycblt.auxio.music
import android.content.Context
import kotlin.math.max
import org.oxycblt.auxio.R
import org.oxycblt.auxio.util.concatLocalized
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.tag.Name
import org.oxycblt.musikr.tag.Placeholder
fun Name.resolve(context: Context) =
when (this) {
is Name.Known -> raw
is Name.Unknown ->
when (placeholder) {
Placeholder.ALBUM -> context.getString(R.string.def_album)
Placeholder.ARTIST -> context.getString(R.string.def_artist)
Placeholder.GENRE -> context.getString(R.string.def_genre)
}
}
/**
* Run [Name.resolve] on each instance in the given list and concatenate them into a [String] in a

View file

@ -29,6 +29,7 @@ import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogDeletePlaylistBinding
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.unlikelyToBeNull

View file

@ -31,6 +31,7 @@ import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogPlaylistExportBinding
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.unlikelyToBeNull

View file

@ -25,6 +25,7 @@ import org.oxycblt.auxio.list.ClickableListListener
import org.oxycblt.auxio.list.adapter.FlexibleListAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.list.recycler.DialogRecyclerView
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.inflater

View file

@ -28,6 +28,7 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.PlaylistDecision
import org.oxycblt.auxio.music.resolve
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song

View file

@ -30,6 +30,7 @@ import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogPlaylistNameBinding
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.unlikelyToBeNull

View file

@ -27,6 +27,7 @@ import androidx.annotation.StringRes
import androidx.media.utils.MediaConstants
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.playback.formatDurationDs
import org.oxycblt.auxio.util.getPlural

View file

@ -26,6 +26,7 @@ import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentPlaybackBarBinding
import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.playback.state.RepeatMode
import org.oxycblt.auxio.ui.ViewBindingFragment

View file

@ -35,6 +35,7 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentPlaybackPanelBinding
import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.list.ListViewModel
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.playback.state.RepeatMode
import org.oxycblt.auxio.playback.ui.StyledSeekBar

View file

@ -25,6 +25,7 @@ import org.oxycblt.auxio.list.ClickableListListener
import org.oxycblt.auxio.list.adapter.FlexibleListAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.list.recycler.DialogRecyclerView
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.inflater
import org.oxycblt.musikr.Artist

View file

@ -25,6 +25,7 @@ import org.oxycblt.auxio.list.ClickableListListener
import org.oxycblt.auxio.list.adapter.FlexibleListAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.list.recycler.DialogRecyclerView
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.inflater
import org.oxycblt.musikr.Genre

View file

@ -32,6 +32,7 @@ import org.oxycblt.auxio.list.adapter.FlexibleListAdapter
import org.oxycblt.auxio.list.adapter.PlayingIndicatorAdapter
import org.oxycblt.auxio.list.recycler.MaterialDragCallback
import org.oxycblt.auxio.list.recycler.SongViewHolder
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getAttrColorCompat

View file

@ -36,6 +36,7 @@ import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R
import org.oxycblt.auxio.image.BitmapProvider
import org.oxycblt.auxio.image.ImageSettings
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.music.service.MediaSessionUID
import org.oxycblt.auxio.music.service.toMediaDescription

View file

@ -30,6 +30,7 @@ import javax.inject.Inject
import org.apache.commons.text.similarity.JaroWinklerSimilarity
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.music.service.MediaSessionUID
import org.oxycblt.auxio.music.service.MusicBrowser
import org.oxycblt.auxio.playback.state.PlaybackCommand

View file

@ -22,6 +22,7 @@ import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import java.text.Normalizer
import javax.inject.Inject
import org.oxycblt.auxio.music.resolve
import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Genre

View file

@ -30,6 +30,7 @@ import android.view.View
import android.widget.RemoteViews
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.playback.service.PlaybackActions
import org.oxycblt.auxio.playback.state.RepeatMode

View file

@ -20,9 +20,9 @@ package org.oxycblt.musikr
import org.oxycblt.musikr.cache.Cache
import org.oxycblt.musikr.cover.StoredCovers
import org.oxycblt.musikr.tag.Name
import org.oxycblt.musikr.tag.interpret.Naming
import org.oxycblt.musikr.tag.interpret.Separators
data class Storage(val cache: Cache, val storedCovers: StoredCovers)
data class Interpretation(val nameFactory: Name.Known.Factory, val separators: Separators)
data class Interpretation(val naming: Naming, val separators: Separators)

View file

@ -115,7 +115,7 @@ private class MusicGraphBuilderImpl : MusicGraph.Builder {
simplifyArtistCluster(cluster)
}
val albumClusters = albumVertices.values.groupBy { it.preAlbum.rawName.lowercase() }
val albumClusters = albumVertices.values.groupBy { it.preAlbum.rawName?.lowercase() }
for (cluster in albumClusters.values) {
simplifyAlbumCluster(cluster)
}

View file

@ -25,6 +25,7 @@ import java.io.BufferedWriter
import java.io.InputStream
import java.io.InputStreamReader
import java.io.OutputStream
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.fs.Components

View file

@ -18,11 +18,7 @@
package org.oxycblt.musikr.tag
import android.content.Context
import androidx.annotation.StringRes
import androidx.annotation.VisibleForTesting
import java.text.CollationKey
import java.text.Collator
import org.oxycblt.musikr.tag.interpret.Token
/**
* The name of a music item.
@ -32,71 +28,26 @@ import java.text.Collator
* @author Alexander Capehart
*/
sealed interface Name : Comparable<Name> {
/**
* A logical first character that can be used to collate a sorted list of music.
*
* TODO: Move this to the home package
*/
val thumb: String
/**
* Get a human-readable string representation of this instance.
*
* @param context [Context] required.
*/
fun resolve(context: Context): String
/** A name that could be obtained for the music item. */
sealed class Known : Name {
abstract class Known : Name {
/** The raw name string obtained. Should be ignored in favor of [resolve]. */
abstract val raw: String
/** The raw sort name string obtained. */
abstract val sort: String?
/** A tokenized version of the name that will be compared. */
@VisibleForTesting(VisibleForTesting.PROTECTED) abstract val sortTokens: List<SortToken>
final override val thumb: String
get() =
// TODO: Remove these safety checks once you have real unit testing
sortTokens
.firstOrNull()
?.run { collationKey.sourceString.firstOrNull() }
?.let { if (it.isDigit()) "#" else it.uppercase() } ?: "?"
final override fun resolve(context: Context) = raw
abstract val tokens: List<Token>
final override fun compareTo(other: Name) =
when (other) {
is Known -> {
val result =
sortTokens.zip(other.sortTokens).fold(0) { acc, (token, otherToken) ->
tokens.zip(other.tokens).fold(0) { acc, (token, otherToken) ->
acc.takeIf { it != 0 } ?: token.compareTo(otherToken)
}
if (result != 0) result else sortTokens.size.compareTo(other.sortTokens.size)
if (result != 0) result else tokens.size.compareTo(other.tokens.size)
}
is Unknown -> 1
}
sealed interface Factory {
/**
* Create a new instance of [Name.Known]
*
* @param raw The raw name obtained from the music item
* @param sort The raw sort name obtained from the music item
*/
fun parse(raw: String, sort: String?): Known
}
/** Produces a simple [Known] with basic sorting heuristics that are locale-independent. */
data object SimpleFactory : Factory {
override fun parse(raw: String, sort: String?) = SimpleKnownName(raw, sort)
}
/** Produces an intelligent [Known] with advanced, but more fragile heuristics. */
data object IntelligentFactory : Factory {
override fun parse(raw: String, sort: String?) = IntelligentKnownName(raw, sort)
}
}
/**
@ -104,11 +55,7 @@ sealed interface Name : Comparable<Name> {
*
* @author Alexander Capehart
*/
data class Unknown(@StringRes val stringRes: Int) : Name {
override val thumb = "?"
override fun resolve(context: Context) = context.getString(stringRes)
data class Unknown(val placeholder: Placeholder) : Name {
override fun compareTo(other: Name) =
when (other) {
// Unknown names do not need any direct comparison right now.
@ -119,111 +66,8 @@ sealed interface Name : Comparable<Name> {
}
}
private val collator: Collator = Collator.getInstance().apply { strength = Collator.PRIMARY }
private val punctRegex by lazy { Regex("[\\p{Punct}+]") }
// TODO: Consider how you want to handle whitespace and "gaps" in names.
/**
* Plain [Name.Known] implementation that is internationalization-safe.
*
* @author Alexander Capehart (OxygenCobalt)
*/
data class SimpleKnownName(override val raw: String, override val sort: String?) : Name.Known() {
override val sortTokens = listOf(parseToken(sort ?: raw))
private fun parseToken(name: String): SortToken {
// Remove excess punctuation from the string, as those usually aren't considered in sorting.
val stripped = name.replace(punctRegex, "").trim().ifEmpty { name }
val collationKey = collator.getCollationKey(stripped)
// Always use lexicographic mode since we aren't parsing any numeric components
return SortToken(collationKey, SortToken.Type.LEXICOGRAPHIC)
}
}
/**
* [Name.Known] implementation that adds advanced sorting behavior at the cost of localization.
*
* @author Alexander Capehart (OxygenCobalt)
*/
data class IntelligentKnownName(override val raw: String, override val sort: String?) :
Name.Known() {
override val sortTokens = parseTokens(sort ?: raw)
private fun parseTokens(name: String): List<SortToken> {
// TODO: This routine is consuming much of the song building runtime, find a way to
// optimize it
val stripped =
name
// Remove excess punctuation from the string, as those usually aren't
// considered in sorting.
.replace(punctRegex, "")
.ifEmpty { name }
.run {
// Strip any english articles like "the" or "an" from the start, as music
// sorting should ignore such when possible.
when {
length > 4 && startsWith("the ", ignoreCase = true) -> substring(4)
length > 3 && startsWith("an ", ignoreCase = true) -> substring(3)
length > 2 && startsWith("a ", ignoreCase = true) -> substring(2)
else -> this
}
}
// To properly compare numeric components in names, we have to split them up into
// individual lexicographic and numeric tokens and then individually compare them
// with special logic.
return TOKEN_REGEX.findAll(stripped).mapTo(mutableListOf()) { match ->
// Remove excess whitespace where possible
val token = match.value.trim().ifEmpty { match.value }
val collationKey: CollationKey
val type: SortToken.Type
// Separate each token into their numeric and lexicographic counterparts.
if (token.first().isDigit()) {
// The digit string comparison breaks with preceding zero digits, remove those
val digits =
token.trimStart { Character.getNumericValue(it) == 0 }.ifEmpty { token }
// Other languages have other types of digit strings, still use collation keys
collationKey = collator.getCollationKey(digits)
type = SortToken.Type.NUMERIC
} else {
collationKey = collator.getCollationKey(token)
type = SortToken.Type.LEXICOGRAPHIC
}
SortToken(collationKey, type)
}
}
companion object {
private val TOKEN_REGEX by lazy { Regex("(\\d+)|(\\D+)") }
}
}
/** An individual part of a name string that can be compared intelligently. */
@VisibleForTesting(VisibleForTesting.PROTECTED)
data class SortToken(val collationKey: CollationKey, val type: Type) : Comparable<SortToken> {
override fun compareTo(other: SortToken): Int {
// Numeric tokens should always be lower than lexicographic tokens.
val modeComp = type.compareTo(other.type)
if (modeComp != 0) {
return modeComp
}
// Numeric strings must be ordered by magnitude, thus immediately short-circuit
// the comparison if the lengths do not match.
if (type == Type.NUMERIC &&
collationKey.sourceString.length != other.collationKey.sourceString.length) {
return collationKey.sourceString.length - other.collationKey.sourceString.length
}
return collationKey.compareTo(other.collationKey)
}
/** Denotes the type of comparison to be performed with this token. */
enum class Type {
/** Compare as a digit string, like "65". */
NUMERIC,
/** Compare as a standard alphanumeric string, like "65daysofstatic" */
LEXICOGRAPHIC
}
enum class Placeholder {
ALBUM,
ARTIST,
GENRE
}

View file

@ -0,0 +1,130 @@
/*
* Copyright (c) 2024 Auxio Project
* Naming.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.musikr.tag.interpret
import java.text.CollationKey
import java.text.Collator
import org.oxycblt.musikr.tag.Name
import org.oxycblt.musikr.tag.Placeholder
abstract class Naming {
fun name(raw: String?, sort: String?, placeholder: Placeholder): Name =
if (raw != null) {
name(raw, sort)
} else {
Name.Unknown(placeholder)
}
abstract fun name(raw: String, sort: String?): Name.Known
companion object {
fun intelligent(): Naming = IntelligentNaming
fun simple(): Naming = SimpleNaming
}
}
private data object IntelligentNaming : Naming() {
override fun name(raw: String, sort: String?) = IntelligentKnownName(raw, sort)
}
private data object SimpleNaming : Naming() {
override fun name(raw: String, sort: String?) = SimpleKnownName(raw, sort)
}
private val collator: Collator = Collator.getInstance().apply { strength = Collator.PRIMARY }
private val punctRegex by lazy { Regex("[\\p{Punct}+]") }
// TODO: Consider how you want to handle whitespace and "gaps" in names.
/**
* Plain [Name.Known] implementation that is internationalization-safe.
*
* @author Alexander Capehart (OxygenCobalt)
*/
private data class SimpleKnownName(override val raw: String, override val sort: String?) :
Name.Known() {
override val tokens = listOf(parseToken(sort ?: raw))
private fun parseToken(name: String): Token {
// Remove excess punctuation from the string, as those usually aren't considered in sorting.
val stripped = name.replace(punctRegex, "").trim().ifEmpty { name }
val collationKey = collator.getCollationKey(stripped)
// Always use lexicographic mode since we aren't parsing any numeric components
return Token(collationKey, Token.Type.LEXICOGRAPHIC)
}
}
/**
* [Name.Known] implementation that adds advanced sorting behavior at the cost of localization.
*
* @author Alexander Capehart (OxygenCobalt)
*/
private data class IntelligentKnownName(override val raw: String, override val sort: String?) :
Name.Known() {
override val tokens = parseTokens(sort ?: raw)
private fun parseTokens(name: String): List<Token> {
// TODO: This routine is consuming much of the song building runtime, find a way to
// optimize it
val stripped =
name
// Remove excess punctuation from the string, as those usually aren't
// considered in sorting.
.replace(punctRegex, "")
.ifEmpty { name }
.run {
// Strip any english articles like "the" or "an" from the start, as music
// sorting should ignore such when possible.
when {
length > 4 && startsWith("the ", ignoreCase = true) -> substring(4)
length > 3 && startsWith("an ", ignoreCase = true) -> substring(3)
length > 2 && startsWith("a ", ignoreCase = true) -> substring(2)
else -> this
}
}
// To properly compare numeric components in names, we have to split them up into
// individual lexicographic and numeric tokens and then individually compare them
// with special logic.
return TOKEN_REGEX.findAll(stripped).mapTo(mutableListOf()) { match ->
// Remove excess whitespace where possible
val token = match.value.trim().ifEmpty { match.value }
val collationKey: CollationKey
val type: Token.Type
// Separate each token into their numeric and lexicographic counterparts.
if (token.first().isDigit()) {
// The digit string comparison breaks with preceding zero digits, remove those
val digits =
token.trimStart { Character.getNumericValue(it) == 0 }.ifEmpty { token }
// Other languages have other types of digit strings, still use collation keys
collationKey = collator.getCollationKey(digits)
type = Token.Type.NUMERIC
} else {
collationKey = collator.getCollationKey(token)
type = Token.Type.LEXICOGRAPHIC
}
Token(collationKey, type)
}
}
companion object {
private val TOKEN_REGEX by lazy { Regex("(\\d+)|(\\D+)") }
}
}

View file

@ -73,7 +73,7 @@ data class PreSong(
data class PreAlbum(
val musicBrainzId: UUID?,
val name: Name,
val rawName: String,
val rawName: String?,
val releaseType: ReleaseType,
val preArtists: List<PreArtist>
)

View file

@ -18,13 +18,12 @@
package org.oxycblt.musikr.tag.interpret
import org.oxycblt.auxio.R
import org.oxycblt.musikr.Interpretation
import org.oxycblt.musikr.fs.Format
import org.oxycblt.musikr.fs.query.DeviceFile
import org.oxycblt.musikr.pipeline.RawSong
import org.oxycblt.musikr.tag.Disc
import org.oxycblt.musikr.tag.Name
import org.oxycblt.musikr.tag.Placeholder
import org.oxycblt.musikr.tag.ReleaseType
import org.oxycblt.musikr.tag.ReplayGainAdjustment
import org.oxycblt.musikr.tag.parse.ParsedTags
@ -54,8 +53,7 @@ private data object TagInterpreterImpl : TagInterpreter {
song.tags.albumArtistSortNames,
interpretation)
val preAlbum =
makePreAlbum(
song.file, song.tags, individualPreArtists, albumPreArtists, interpretation)
makePreAlbum(song.tags, individualPreArtists, albumPreArtists, interpretation)
val rawArtists =
individualPreArtists.ifEmpty { albumPreArtists }.ifEmpty { listOf(unknownPreArtist()) }
val rawGenres =
@ -70,7 +68,7 @@ private data object TagInterpreterImpl : TagInterpreter {
// TODO: Figure out what to do with date added
dateAdded = song.file.lastModified,
musicBrainzId = song.tags.musicBrainzId?.toUuidOrNull(),
name = interpretation.nameFactory.parse(song.tags.name, song.tags.sortName),
name = interpretation.naming.name(song.tags.name, song.tags.sortName),
rawName = song.tags.name,
track = song.tags.track,
disc = song.tags.disc?.let { Disc(it, song.tags.subtitle) },
@ -88,18 +86,17 @@ private data object TagInterpreterImpl : TagInterpreter {
}
private fun makePreAlbum(
file: DeviceFile,
parsedTags: ParsedTags,
individualPreArtists: List<PreArtist>,
albumPreArtists: List<PreArtist>,
interpretation: Interpretation
): PreAlbum {
// TODO: Make fallbacks for this!
val rawAlbumName = requireNotNull(parsedTags.albumName)
return PreAlbum(
musicBrainzId = parsedTags.albumMusicBrainzId?.toUuidOrNull(),
name = interpretation.nameFactory.parse(rawAlbumName, parsedTags.albumSortName),
rawName = rawAlbumName,
name =
interpretation.naming.name(
parsedTags.albumName, parsedTags.albumSortName, Placeholder.ALBUM),
rawName = parsedTags.albumName,
releaseType =
ReleaseType.parse(interpretation.separators.split(parsedTags.releaseTypes))
?: ReleaseType.Album(null),
@ -129,14 +126,12 @@ private data object TagInterpreterImpl : TagInterpreter {
sortName: String?,
interpretation: Interpretation
): PreArtist {
val name =
rawName?.let { interpretation.nameFactory.parse(it, sortName) }
?: Name.Unknown(R.string.def_artist)
val name = interpretation.naming.name(rawName, null, Placeholder.ARTIST)
val musicBrainzId = musicBrainzId?.toUuidOrNull()
return PreArtist(musicBrainzId, name, rawName)
}
private fun unknownPreArtist() = PreArtist(null, Name.Unknown(R.string.def_artist), null)
private fun unknownPreArtist() = PreArtist(null, Name.Unknown(Placeholder.GENRE), null)
private fun makePreGenres(
parsedTags: ParsedTags,
@ -149,10 +144,7 @@ private data object TagInterpreterImpl : TagInterpreter {
}
private fun makePreGenre(rawName: String?, interpretation: Interpretation) =
PreGenre(
rawName?.let { interpretation.nameFactory.parse(it, null) }
?: Name.Unknown(R.string.def_genre),
rawName)
PreGenre(interpretation.naming.name(rawName, null, Placeholder.GENRE), rawName)
private fun unknownPreGenre() = PreGenre(Name.Unknown(R.string.def_genre), null)
private fun unknownPreGenre() = PreGenre(Name.Unknown(Placeholder.GENRE), null)
}

View file

@ -0,0 +1,49 @@
/*
* Copyright (c) 2024 Auxio Project
* Token.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.musikr.tag.interpret
import java.text.CollationKey
/** An individual part of a name string that can be compared intelligently. */
data class Token(val collationKey: CollationKey, val type: Type) : Comparable<Token> {
override fun compareTo(other: Token): Int {
// Numeric tokens should always be lower than lexicographic tokens.
val modeComp = type.compareTo(other.type)
if (modeComp != 0) {
return modeComp
}
// Numeric strings must be ordered by magnitude, thus immediately short-circuit
// the comparison if the lengths do not match.
if (type == Type.NUMERIC &&
collationKey.sourceString.length != other.collationKey.sourceString.length) {
return collationKey.sourceString.length - other.collationKey.sourceString.length
}
return collationKey.compareTo(other.collationKey)
}
/** Denotes the type of comparison to be performed with this token. */
enum class Type {
/** Compare as a digit string, like "65". */
NUMERIC,
/** Compare as a standard alphanumeric string, like "65daysofstatic" */
LEXICOGRAPHIC
}
}

View file

@ -20,9 +20,9 @@ package org.oxycblt.musikr.util
import java.security.MessageDigest
import java.util.UUID
import kotlin.reflect.KClass
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.musikr.tag.Date
import kotlin.reflect.KClass
/**
* Sanitizes a value that is unlikely to be null. On debug builds, this aliases to [requireNotNull],
@ -130,4 +130,4 @@ fun lazyReflectedMethod(clazz: KClass<*>, method: String, vararg params: KClass<
clazz.java.getDeclaredMethod(method, *params.map { it.java }.toTypedArray()).also {
it.isAccessible = true
}
}
}

View file

@ -361,6 +361,7 @@
<!-- Default Namespace | Placeholder values -->
<eat-comment />
<string name="def_album">Unknown album</string>
<string name="def_artist">Unknown artist</string>
<string name="def_genre">Unknown genre</string>
<string name="def_date">No date</string>

View file

@ -31,7 +31,7 @@ class NameTest {
assertEquals("L", name.thumb)
val only = name.sortTokens.single()
assertEquals("Loveless", only.collationKey.sourceString)
assertEquals(SortToken.Type.LEXICOGRAPHIC, only.type)
assertEquals(Token.Type.LEXICOGRAPHIC, only.type)
}
@Test
@ -42,7 +42,7 @@ class NameTest {
assertEquals("A", name.thumb)
val only = name.sortTokens.single()
assertEquals("altJ", only.collationKey.sourceString)
assertEquals(SortToken.Type.LEXICOGRAPHIC, only.type)
assertEquals(Token.Type.LEXICOGRAPHIC, only.type)
}
@Test
@ -53,7 +53,7 @@ class NameTest {
assertEquals("!", name.thumb)
val only = name.sortTokens.single()
assertEquals("!!!", only.collationKey.sourceString)
assertEquals(SortToken.Type.LEXICOGRAPHIC, only.type)
assertEquals(Token.Type.LEXICOGRAPHIC, only.type)
}
@Test
@ -64,7 +64,7 @@ class NameTest {
assertEquals("Y", name.thumb)
val first = name.sortTokens[0]
assertEquals("Yet Yet", first.collationKey.sourceString)
assertEquals(SortToken.Type.LEXICOGRAPHIC, first.type)
assertEquals(Token.Type.LEXICOGRAPHIC, first.type)
}
@Test
@ -75,7 +75,7 @@ class NameTest {
assertEquals("S", name.thumb)
val only = name.sortTokens.single()
assertEquals("Smile", only.collationKey.sourceString)
assertEquals(SortToken.Type.LEXICOGRAPHIC, only.type)
assertEquals(Token.Type.LEXICOGRAPHIC, only.type)
}
@Test
@ -86,7 +86,7 @@ class NameTest {
assertEquals("L", name.thumb)
val only = name.sortTokens.single()
assertEquals("Loveless", only.collationKey.sourceString)
assertEquals(SortToken.Type.LEXICOGRAPHIC, only.type)
assertEquals(Token.Type.LEXICOGRAPHIC, only.type)
}
@Test
@ -97,10 +97,10 @@ class NameTest {
assertEquals("#", name.thumb)
val first = name.sortTokens[0]
assertEquals("15", first.collationKey.sourceString)
assertEquals(SortToken.Type.NUMERIC, first.type)
assertEquals(Token.Type.NUMERIC, first.type)
val second = name.sortTokens[1]
assertEquals("Step", second.collationKey.sourceString)
assertEquals(SortToken.Type.LEXICOGRAPHIC, second.type)
assertEquals(Token.Type.LEXICOGRAPHIC, second.type)
}
@Test
@ -111,10 +111,10 @@ class NameTest {
assertEquals("#", name.thumb)
val first = name.sortTokens[0]
assertEquals("23", first.collationKey.sourceString)
assertEquals(SortToken.Type.NUMERIC, first.type)
assertEquals(Token.Type.NUMERIC, first.type)
val second = name.sortTokens[1]
assertEquals("Kid", second.collationKey.sourceString)
assertEquals(SortToken.Type.LEXICOGRAPHIC, second.type)
assertEquals(Token.Type.LEXICOGRAPHIC, second.type)
}
@Test
@ -125,19 +125,19 @@ class NameTest {
assertEquals("F", name.thumb)
val first = name.sortTokens[0]
assertEquals("Foo", first.collationKey.sourceString)
assertEquals(SortToken.Type.LEXICOGRAPHIC, first.type)
assertEquals(Token.Type.LEXICOGRAPHIC, first.type)
val second = name.sortTokens[1]
assertEquals("1", second.collationKey.sourceString)
assertEquals(SortToken.Type.NUMERIC, second.type)
assertEquals(Token.Type.NUMERIC, second.type)
val third = name.sortTokens[2]
assertEquals(" ", third.collationKey.sourceString)
assertEquals(SortToken.Type.LEXICOGRAPHIC, third.type)
assertEquals(Token.Type.LEXICOGRAPHIC, third.type)
val fourth = name.sortTokens[3]
assertEquals("2", fourth.collationKey.sourceString)
assertEquals(SortToken.Type.NUMERIC, fourth.type)
assertEquals(Token.Type.NUMERIC, fourth.type)
val fifth = name.sortTokens[4]
assertEquals("Bar", fifth.collationKey.sourceString)
assertEquals(SortToken.Type.LEXICOGRAPHIC, fifth.type)
assertEquals(Token.Type.LEXICOGRAPHIC, fifth.type)
}
@Test
@ -148,13 +148,13 @@ class NameTest {
assertEquals("F", name.thumb)
val first = name.sortTokens[0]
assertEquals("Foo", first.collationKey.sourceString)
assertEquals(SortToken.Type.LEXICOGRAPHIC, first.type)
assertEquals(Token.Type.LEXICOGRAPHIC, first.type)
val second = name.sortTokens[1]
assertEquals("12", second.collationKey.sourceString)
assertEquals(SortToken.Type.NUMERIC, second.type)
assertEquals(Token.Type.NUMERIC, second.type)
val third = name.sortTokens[2]
assertEquals("Bar", third.collationKey.sourceString)
assertEquals(SortToken.Type.LEXICOGRAPHIC, third.type)
assertEquals(Token.Type.LEXICOGRAPHIC, third.type)
}
@Test
@ -165,10 +165,10 @@ class NameTest {
assertEquals("F", name.thumb)
val first = name.sortTokens[0]
assertEquals("Foo", first.collationKey.sourceString)
assertEquals(SortToken.Type.LEXICOGRAPHIC, first.type)
assertEquals(Token.Type.LEXICOGRAPHIC, first.type)
val second = name.sortTokens[1]
assertEquals("1", second.collationKey.sourceString)
assertEquals(SortToken.Type.NUMERIC, second.type)
assertEquals(Token.Type.NUMERIC, second.type)
}
@Test
@ -179,10 +179,10 @@ class NameTest {
assertEquals("E", name.thumb)
val first = name.sortTokens[0]
assertEquals("Error", first.collationKey.sourceString)
assertEquals(SortToken.Type.LEXICOGRAPHIC, first.type)
assertEquals(Token.Type.LEXICOGRAPHIC, first.type)
val second = name.sortTokens[1]
assertEquals("404", second.collationKey.sourceString)
assertEquals(SortToken.Type.NUMERIC, second.type)
assertEquals(Token.Type.NUMERIC, second.type)
}
@Test
@ -193,7 +193,7 @@ class NameTest {
assertEquals("N", name.thumb)
val first = name.sortTokens[0]
assertEquals("National Anthem", first.collationKey.sourceString)
assertEquals(SortToken.Type.LEXICOGRAPHIC, first.type)
assertEquals(Token.Type.LEXICOGRAPHIC, first.type)
}
@Test
@ -204,7 +204,7 @@ class NameTest {
assertEquals("E", name.thumb)
val first = name.sortTokens[0]
assertEquals("Eagle in Your Mind", first.collationKey.sourceString)
assertEquals(SortToken.Type.LEXICOGRAPHIC, first.type)
assertEquals(Token.Type.LEXICOGRAPHIC, first.type)
}
@Test
@ -215,7 +215,7 @@ class NameTest {
assertEquals("S", name.thumb)
val first = name.sortTokens[0]
assertEquals("Song For Our Fathers", first.collationKey.sourceString)
assertEquals(SortToken.Type.LEXICOGRAPHIC, first.type)
assertEquals(Token.Type.LEXICOGRAPHIC, first.type)
}
@Test
@ -226,7 +226,7 @@ class NameTest {
assertEquals("A", name.thumb)
val only = name.sortTokens.single()
assertEquals("altJ", only.collationKey.sourceString)
assertEquals(SortToken.Type.LEXICOGRAPHIC, only.type)
assertEquals(Token.Type.LEXICOGRAPHIC, only.type)
}
@Test
@ -237,7 +237,7 @@ class NameTest {
assertEquals("!", name.thumb)
val only = name.sortTokens.single()
assertEquals("!!!", only.collationKey.sourceString)
assertEquals(SortToken.Type.LEXICOGRAPHIC, only.type)
assertEquals(Token.Type.LEXICOGRAPHIC, only.type)
}
@Test
@ -248,7 +248,7 @@ class NameTest {
assertEquals("#", name.thumb)
val first = name.sortTokens[0]
assertEquals("1", first.collationKey.sourceString)
assertEquals(SortToken.Type.NUMERIC, first.type)
assertEquals(Token.Type.NUMERIC, first.type)
}
@Test
@ -259,7 +259,7 @@ class NameTest {
assertEquals("Y", name.thumb)
val first = name.sortTokens[0]
assertEquals("Yet Yet", first.collationKey.sourceString)
assertEquals(SortToken.Type.LEXICOGRAPHIC, first.type)
assertEquals(Token.Type.LEXICOGRAPHIC, first.type)
}
@Test
@ -270,16 +270,16 @@ class NameTest {
assertEquals("D", name.thumb)
val first = name.sortTokens[0]
assertEquals("Design", first.collationKey.sourceString)
assertEquals(SortToken.Type.LEXICOGRAPHIC, first.type)
assertEquals(Token.Type.LEXICOGRAPHIC, first.type)
val second = name.sortTokens[1]
assertEquals("2", second.collationKey.sourceString)
assertEquals(SortToken.Type.NUMERIC, second.type)
assertEquals(Token.Type.NUMERIC, second.type)
val third = name.sortTokens[2]
assertEquals(" ", third.collationKey.sourceString)
assertEquals(SortToken.Type.LEXICOGRAPHIC, third.type)
assertEquals(Token.Type.LEXICOGRAPHIC, third.type)
val fourth = name.sortTokens[3]
assertEquals("3", fourth.collationKey.sourceString)
assertEquals(SortToken.Type.NUMERIC, fourth.type)
assertEquals(Token.Type.NUMERIC, fourth.type)
}
@Test
@ -290,19 +290,19 @@ class NameTest {
assertEquals("#", name.thumb)
val first = name.sortTokens[0]
assertEquals("2", first.collationKey.sourceString)
assertEquals(SortToken.Type.NUMERIC, first.type)
assertEquals(Token.Type.NUMERIC, first.type)
val second = name.sortTokens[1]
assertEquals(" ", second.collationKey.sourceString)
assertEquals(SortToken.Type.LEXICOGRAPHIC, second.type)
assertEquals(Token.Type.LEXICOGRAPHIC, second.type)
val third = name.sortTokens[2]
assertEquals("2", third.collationKey.sourceString)
assertEquals(SortToken.Type.NUMERIC, third.type)
assertEquals(Token.Type.NUMERIC, third.type)
val fourth = name.sortTokens[3]
assertEquals(" ", fourth.collationKey.sourceString)
assertEquals(SortToken.Type.LEXICOGRAPHIC, fourth.type)
assertEquals(Token.Type.LEXICOGRAPHIC, fourth.type)
val fifth = name.sortTokens[4]
assertEquals("5", fifth.collationKey.sourceString)
assertEquals(SortToken.Type.NUMERIC, fifth.type)
assertEquals(Token.Type.NUMERIC, fifth.type)
}
@Test
@ -313,7 +313,7 @@ class NameTest {
assertEquals("S", name.thumb)
val only = name.sortTokens.single()
assertEquals("Smile", only.collationKey.sourceString)
assertEquals(SortToken.Type.LEXICOGRAPHIC, only.type)
assertEquals(Token.Type.LEXICOGRAPHIC, only.type)
}
@Test