Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .idea/deploymentTargetSelector.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -1653,6 +1653,7 @@ interface MusicDao {
artist_id = :artistId,
artists_json = :artistsJson,
album_name = :album,
album_artist = :albumArtist,
genre = :genre,
track_number = :trackNumber,
disc_number = :discNumber
Expand All @@ -1665,6 +1666,7 @@ interface MusicDao {
artistId: Long,
artistsJson: String?,
album: String,
albumArtist: String?,
genre: String?,
trackNumber: Int,
discNumber: Int?
Expand All @@ -1678,6 +1680,7 @@ interface MusicDao {
artistId: Long,
artistsJson: String?,
album: String,
albumArtist: String?,
genre: String?,
trackNumber: Int,
discNumber: Int?,
Expand All @@ -1695,6 +1698,7 @@ interface MusicDao {
artistId = artistId,
artistsJson = artistsJson,
album = album,
albumArtist = albumArtist,
genre = genre,
trackNumber = trackNumber,
discNumber = discNumber
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import com.theveloper.pixelplay.data.database.SongEntity
import com.theveloper.pixelplay.data.database.SourceType
import com.theveloper.pixelplay.data.database.toSong
import com.theveloper.pixelplay.data.model.Song
import com.theveloper.pixelplay.data.stream.CloudMusicUtils
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
Expand Down Expand Up @@ -526,14 +527,8 @@ class GDriveRepository @Inject constructor(
)
}

private fun parseArtistNames(rawArtist: String): List<String> {
if (rawArtist.isBlank()) return listOf("Unknown Artist")
val parsed = rawArtist.split(Regex("\\s*[,/&;+]\\s*"))
.map { it.trim() }
.filter { it.isNotBlank() }
.distinct()
return if (parsed.isEmpty()) listOf("Unknown Artist") else parsed
}
private fun parseArtistNames(rawArtist: String): List<String> =
CloudMusicUtils.parseArtistNames(rawArtist)

private fun toUnifiedSongId(driveFileId: String): Long {
return -(GDRIVE_SONG_ID_OFFSET + driveFileId.hashCode().toLong().absoluteValue)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ class SongMetadataEditor(
title: String,
artist: String,
album: String,
albumArtist: String?,
genre: String?,
trackNumber: Int,
discNumber: Int?
Expand Down Expand Up @@ -222,6 +223,7 @@ class SongMetadataEditor(
artistId = primaryArtistId,
artistsJson = artistsJson,
album = album,
albumArtist = albumArtist,
genre = genre,
trackNumber = trackNumber,
discNumber = discNumber,
Expand Down Expand Up @@ -525,6 +527,7 @@ class SongMetadataEditor(
title = newTitle,
artist = newArtist,
album = newAlbum,
albumArtist = newAlbumArtist,
genre = normalizedGenre,
trackNumber = newTrackNumber,
discNumber = newDiscNumber
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -995,7 +995,11 @@ suspend fun markDirectoryRulesVersionApplied(version: Int) {
// ─── Multi-artist settings ────────────────────────────────────────────────

val artistDelimitersFlow: Flow<List<String>> =
pref { decodeJsonPref(it, PreferencesKeys.ARTIST_DELIMITERS, DEFAULT_ARTIST_DELIMITERS) }
pref {
normalizeLegacyDefaultArtistDelimiters(
decodeJsonPref(it, PreferencesKeys.ARTIST_DELIMITERS, DEFAULT_ARTIST_DELIMITERS)
)
}

suspend fun setArtistDelimiters(delimiters: List<String>) {
if (delimiters.isEmpty()) return
Expand Down Expand Up @@ -1347,7 +1351,9 @@ suspend fun markDirectoryRulesVersionApplied(version: Int) {

companion object {
/** Default character delimiters for splitting multi-artist tags. */
val DEFAULT_ARTIST_DELIMITERS = listOf("/", ";", ",", "+", "&")
val DEFAULT_ARTIST_DELIMITERS = listOf(";")

private val LEGACY_DEFAULT_ARTIST_DELIMITERS = listOf("/", ";", ",", "+", "&")

/** Default word-based delimiters matched case-insensitively with whitespace boundaries. */
val DEFAULT_ARTIST_WORD_DELIMITERS = listOf(
Expand All @@ -1360,6 +1366,9 @@ suspend fun markDirectoryRulesVersionApplied(version: Int) {

// ─── Private utilities ────────────────────────────────────────────────────

private fun normalizeLegacyDefaultArtistDelimiters(delimiters: List<String>): List<String> =
if (delimiters == LEGACY_DEFAULT_ARTIST_DELIMITERS) DEFAULT_ARTIST_DELIMITERS else delimiters

/** Increments [value] by 1, wrapping back to 0 on overflow. */
private fun incrementWrapped(value: Int?) =
if (value == null || value == Int.MAX_VALUE) 0 else value + 1
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.theveloper.pixelplay.data.stream

import com.theveloper.pixelplay.data.preferences.UserPreferencesRepository
import com.theveloper.pixelplay.utils.splitArtistsByDelimiters
import org.json.JSONObject

/**
Expand All @@ -26,13 +28,13 @@ object CloudMusicUtils {
return result
}

/** Split a raw artist string like "A, B & C" into individual names. */
/** Split a raw artist string using the same conservative defaults as local library sync. */
fun parseArtistNames(rawArtist: String): List<String> {
if (rawArtist.isBlank()) return listOf("Unknown Artist")
val parsed = rawArtist.split(Regex("\\s*[,/&;+、]\\s*"))
.map { it.trim() }
.filter { it.isNotBlank() }
.distinct()
val parsed = rawArtist.splitArtistsByDelimiters(
delimiters = UserPreferencesRepository.DEFAULT_ARTIST_DELIMITERS,
wordDelimiters = UserPreferencesRepository.DEFAULT_ARTIST_WORD_DELIMITERS
)
return if (parsed.isEmpty()) listOf("Unknown Artist") else parsed
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,9 @@ internal fun buildAlbumGroupingKeys(album: AlbumEntity): List<AlbumGroupingKey>

internal fun chooseAlbumDisplayArtist(
songs: List<SongEntity>,
preferAlbumArtist: Boolean
preferAlbumArtist: Boolean,
artistDelimiters: List<String> = emptyList(),
wordDelimiters: List<String> = emptyList()
): String {
if (songs.isEmpty()) return "Unknown Artist"

Expand All @@ -78,7 +80,13 @@ internal fun chooseAlbumDisplayArtist(
)
val trackArtist = mostCommonValue(
songs.map { song ->
song.artistName.normalizeMetadataTextOrEmpty()
collectArtistNames(
rawArtistName = song.artistName,
title = song.title,
artistDelimiters = artistDelimiters,
wordDelimiters = wordDelimiters,
extractFromTitle = true
).firstOrNull().normalizeMetadataTextOrEmpty()
}
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -620,7 +620,9 @@ constructor(
val representativeAlbumArt = songsInAlbum.firstNotNullOfOrNull { it.albumArtUriString }
val determinedAlbumArtist = chooseAlbumDisplayArtist(
songs = songsInAlbum,
preferAlbumArtist = groupByAlbumArtist
preferAlbumArtist = groupByAlbumArtist,
artistDelimiters = artistDelimiters,
wordDelimiters = wordDelimiters
)
val determinedAlbumArtistId = resolveAlbumDisplayArtistId(
displayArtist = determinedAlbumArtist,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,34 @@ import java.nio.file.Files

class UserPreferencesRepositoryTest {

@Test
fun `default artist delimiters avoid common characters inside artist names`() {
assertEquals(listOf(";"), UserPreferencesRepository.DEFAULT_ARTIST_DELIMITERS)
}

@Test
fun `artistDelimitersFlow normalizes stored legacy defaults`() = runTest {
val tempDir = Files.createTempDirectory("user-preferences-repository-test")
try {
val repository = UserPreferencesRepository(
dataStore = PreferenceDataStoreFactory.create(
scope = backgroundScope,
produceFile = { tempDir.resolve("settings.preferences_pb").toFile() }
),
json = Json
)

repository.setArtistDelimiters(listOf("/", ";", ",", "+", "&"))

assertEquals(
UserPreferencesRepository.DEFAULT_ARTIST_DELIMITERS,
repository.artistDelimitersFlow.first()
)
} finally {
tempDir.toFile().deleteRecursively()
}
}

@Test
fun `clearPreferencesExceptKeys preserves initial setup completion`() = runTest {
val tempDir = Files.createTempDirectory("user-preferences-repository-test")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.theveloper.pixelplay.data.stream

import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test

class CloudMusicUtilsTest {

@Test
fun `parseArtistNames preserves common punctuation in artist names by default`() {
assertEquals(listOf("W&W"), CloudMusicUtils.parseArtistNames("W&W"))
assertEquals(listOf("AC/DC"), CloudMusicUtils.parseArtistNames("AC/DC"))
assertEquals(listOf("Lost & Found"), CloudMusicUtils.parseArtistNames("Lost & Found"))
assertEquals(listOf("Black Country, New Road"), CloudMusicUtils.parseArtistNames("Black Country, New Road"))
}

@Test
fun `parseArtistNames still splits explicit semicolon artists`() {
assertEquals(
listOf("Artist One", "Artist Two"),
CloudMusicUtils.parseArtistNames("Artist One; Artist Two")
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,24 @@ class AlbumGroupingUtilsTest {
assertThat(displayArtist).isEqualTo("The Weeknd")
}

@Test
fun `chooseAlbumDisplayArtist uses primary parsed artist for feature-heavy albums`() {
val songs = listOf(
testSong(artistName = "Gorillaz feat. Stevie Nicks", albumArtist = null),
testSong(artistName = "Gorillaz feat. Thundercat", albumArtist = null),
testSong(artistName = "Gorillaz feat. Tame Impala", albumArtist = null)
)

val displayArtist = chooseAlbumDisplayArtist(
songs = songs,
preferAlbumArtist = false,
artistDelimiters = listOf(";"),
wordDelimiters = listOf("feat.")
)

assertThat(displayArtist).isEqualTo("Gorillaz")
}

@Test
fun `chooseAlbumDisplayArtist prefers album artist when grouping is on`() {
val songs = listOf(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,50 @@ package com.theveloper.pixelplay.data.worker

import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import com.theveloper.pixelplay.data.preferences.UserPreferencesRepository

class ArtistParsingUtilsTest {

@Test
fun `default delimiters preserve ampersand slash comma and plus inside artist names`() {
assertEquals(
listOf("W&W"),
collectArtistNames(
rawArtistName = "W&W",
title = "Rave Culture",
artistDelimiters = UserPreferencesRepository.DEFAULT_ARTIST_DELIMITERS,
wordDelimiters = UserPreferencesRepository.DEFAULT_ARTIST_WORD_DELIMITERS
)
)
assertEquals(
listOf("AC/DC"),
collectArtistNames(
rawArtistName = "AC/DC",
title = "Back In Black",
artistDelimiters = UserPreferencesRepository.DEFAULT_ARTIST_DELIMITERS,
wordDelimiters = UserPreferencesRepository.DEFAULT_ARTIST_WORD_DELIMITERS
)
)
assertEquals(
listOf("Lost & Found"),
collectArtistNames(
rawArtistName = "Lost & Found",
title = "Found",
artistDelimiters = UserPreferencesRepository.DEFAULT_ARTIST_DELIMITERS,
wordDelimiters = UserPreferencesRepository.DEFAULT_ARTIST_WORD_DELIMITERS
)
)
assertEquals(
listOf("Black Country, New Road"),
collectArtistNames(
rawArtistName = "Black Country, New Road",
title = "Track X",
artistDelimiters = UserPreferencesRepository.DEFAULT_ARTIST_DELIMITERS,
wordDelimiters = UserPreferencesRepository.DEFAULT_ARTIST_WORD_DELIMITERS
)
)
}

@Test
fun `choosePreferredArtistName prefers media store when it contains more artists`() {
val result =
Expand Down
Loading