mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-20 04:30:54 +02:00
added desktop parity multi artist metadata
This commit is contained in:
+1166
File diff suppressed because it is too large
Load Diff
+62
@@ -0,0 +1,62 @@
|
|||||||
|
package expo.modules.astralibraryscanner.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.util.Base64
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import java.io.File
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class ArtistCreditMetadataReaderTest {
|
||||||
|
private lateinit var fixture: File
|
||||||
|
private lateinit var invalidFixture: File
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun writeFixtures() {
|
||||||
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
|
fixture = File(context.cacheDir, "repeated-artists.opus").apply {
|
||||||
|
writeBytes(Base64.decode(OPUS_FIXTURE_BASE64, Base64.DEFAULT))
|
||||||
|
}
|
||||||
|
invalidFixture = File(context.cacheDir, "invalid-artists.opus").apply {
|
||||||
|
writeBytes(byteArrayOf(0x00, 0x01, 0x02))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun removeFixtures() {
|
||||||
|
fixture.delete()
|
||||||
|
invalidFixture.delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun exoMetadataStackReturnsEveryRepeatedOpusCredit() {
|
||||||
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
|
|
||||||
|
val credits = ArtistCreditMetadataReader.read(context, Uri.fromFile(fixture), 12_000)
|
||||||
|
|
||||||
|
assertEquals(listOf("Earth, Wind & Fire", "The Emotions"), credits.artists)
|
||||||
|
assertEquals(listOf("Curator One", "Curator Two"), credits.albumArtists)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun unreadableContainerFallsBackToEmptyCredits() {
|
||||||
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
ArtistCreditNames(),
|
||||||
|
ArtistCreditMetadataReader.read(context, Uri.fromFile(invalidFixture), 1_000),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
// 80 ms of silent Opus audio with two ARTIST and two ALBUMARTIST comments.
|
||||||
|
const val OPUS_FIXTURE_BASE64 =
|
||||||
|
"T2dnUwACAAAAAAAAAACRB/HqAAAAANqihuMBE09wdXNIZWFkAQE4AYC7AAAAAABPZ2dTAAAAAAAAAAAAAJEH8eoBAAAArQRGQgGoT3B1c1RhZ3MNAAAAQXN0cmEgZml4dHVyZQUAAAAZAAAAQVJUSVNUPUVhcnRoLCBXaW5kICYgRmlyZRMAAABBUlRJU1Q9VGhlIEVtb3Rpb25zFwAAAEFMQlVNQVJUSVNUPUN1cmF0b3IgT25lFwAAAEFMQlVNQVJUSVNUPUN1cmF0b3IgVHdvHQAAAFRJVExFPVJlcGVhdGVkIEFydGlzdCBGaXh0dXJlT2dnUwAEOBAAAAAAAACRB/HqAgAAAGOKDDAFBwYGBgYIC+S5oLyECAfGsw7GCAfGsw7GCAfGsw7GCAfGsw7G"
|
||||||
|
}
|
||||||
|
}
|
||||||
+77
@@ -0,0 +1,77 @@
|
|||||||
|
package expo.modules.astralibraryscanner.data
|
||||||
|
|
||||||
|
import androidx.room.testing.MigrationTestHelper
|
||||||
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class CatalogMigrationTest {
|
||||||
|
@get:Rule
|
||||||
|
val helper = MigrationTestHelper(
|
||||||
|
InstrumentationRegistry.getInstrumentation(),
|
||||||
|
AstraCatalogDatabase::class.java,
|
||||||
|
)
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun cleanUp() {
|
||||||
|
InstrumentationRegistry.getInstrumentation().targetContext.deleteDatabase(TEST_DATABASE)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun migrationAddsCreditColumnsAndMarksOnlyLocalSourcesStale() {
|
||||||
|
helper.createDatabase(TEST_DATABASE, 1).apply {
|
||||||
|
insertSource("local:1", "local", 1)
|
||||||
|
insertSource("jellyfin:2", "jellyfin", 2)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
|
||||||
|
val database = helper.runMigrationsAndValidate(
|
||||||
|
TEST_DATABASE,
|
||||||
|
2,
|
||||||
|
true,
|
||||||
|
CATALOG_MIGRATION_1_2,
|
||||||
|
)
|
||||||
|
|
||||||
|
database.query(
|
||||||
|
"SELECT source_key, artist_credit_version FROM catalog_sources ORDER BY source_key",
|
||||||
|
).use { cursor ->
|
||||||
|
assertTrue(cursor.moveToFirst())
|
||||||
|
assertEquals("jellyfin:2", cursor.getString(0))
|
||||||
|
assertEquals(CURRENT_ARTIST_CREDIT_VERSION, cursor.getInt(1))
|
||||||
|
assertTrue(cursor.moveToNext())
|
||||||
|
assertEquals("local:1", cursor.getString(0))
|
||||||
|
assertEquals(LEGACY_ARTIST_CREDIT_VERSION, cursor.getInt(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
database.query("PRAGMA table_info(tracks)").use { cursor ->
|
||||||
|
val columnNames = buildSet {
|
||||||
|
val nameIndex = cursor.getColumnIndexOrThrow("name")
|
||||||
|
while (cursor.moveToNext()) add(cursor.getString(nameIndex))
|
||||||
|
}
|
||||||
|
assertTrue("artist_names_json" in columnNames)
|
||||||
|
assertTrue("album_artist_names_json" in columnNames)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun SupportSQLiteDatabase.insertSource(key: String, type: String, id: Long) {
|
||||||
|
execSQL(
|
||||||
|
"""
|
||||||
|
INSERT INTO catalog_sources
|
||||||
|
(source_key, source_type, source_id, active_generation_id, updated_at)
|
||||||
|
VALUES (?, ?, ?, NULL, 0)
|
||||||
|
""".trimIndent(),
|
||||||
|
arrayOf<Any>(key, type, id),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TEST_DATABASE = "artist-credit-migration-test"
|
||||||
|
}
|
||||||
|
}
|
||||||
+70
@@ -495,6 +495,76 @@ class RoomLibraryRepositoryTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun structuredArtistCreditsPreserveNamesContainingPunctuation() = runBlocking {
|
||||||
|
val artistNames = listOf("Earth, Wind & Fire", "The Emotions")
|
||||||
|
val display = formatArtistNames(artistNames)
|
||||||
|
publish(
|
||||||
|
"credits",
|
||||||
|
listOf(
|
||||||
|
track("credits", 0, "Best of My Love").copy(
|
||||||
|
artist = display,
|
||||||
|
artistNamesJson = serializeArtistNames(artistNames),
|
||||||
|
artistSortKey = SortKeys.forText(display),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val dao = catalog.catalogDao()
|
||||||
|
val revision = dao.getRevision()
|
||||||
|
|
||||||
|
val astraArtists = dao.getAllArtistSummaries(revision, "astra")
|
||||||
|
assertEquals(setOf("Earth, Wind & Fire", "The Emotions"), astraArtists.map { it.artist }.toSet())
|
||||||
|
assertEquals(
|
||||||
|
1,
|
||||||
|
dao.countArtistTracks(revision, "astra", "earth, wind & fire", "songs"),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
1,
|
||||||
|
dao.countArtistTracks(revision, "astra", "the emotions", "appearances"),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
listOf(display),
|
||||||
|
dao.getAllArtistSummaries(revision, "fileTags").map { it.artist },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun staleArtistCreditVersionAdvancesOnlyWhenGenerationPublishes() = runBlocking {
|
||||||
|
val dao = catalog.catalogDao()
|
||||||
|
dao.insertMeta(CatalogMetaEntity(collationVersion = COLLATION_VERSION, updatedAt = 0))
|
||||||
|
dao.putSource(
|
||||||
|
CatalogSourceEntity(
|
||||||
|
sourceKey = "local:1",
|
||||||
|
sourceType = "local",
|
||||||
|
sourceId = 1,
|
||||||
|
activeGenerationId = null,
|
||||||
|
updatedAt = 0,
|
||||||
|
artistCreditVersion = LEGACY_ARTIST_CREDIT_VERSION,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
dao.insertGeneration(ScanGenerationEntity("cancelled", "local:1", "staging", 1))
|
||||||
|
dao.deleteGenerationTracks("cancelled")
|
||||||
|
dao.deleteGeneration("cancelled")
|
||||||
|
assertEquals(LEGACY_ARTIST_CREDIT_VERSION, dao.getSource("local:1")?.artistCreditVersion)
|
||||||
|
|
||||||
|
dao.insertGeneration(ScanGenerationEntity("complete", "local:1", "staging", 2))
|
||||||
|
dao.publishGeneration(
|
||||||
|
sourceKey = "local:1",
|
||||||
|
generationId = "complete",
|
||||||
|
previousGenerationId = null,
|
||||||
|
now = 2,
|
||||||
|
albumIdentityUpdates = emptyList(),
|
||||||
|
albums = emptyList(),
|
||||||
|
artists = emptyList(),
|
||||||
|
artistTrackIndex = emptyList(),
|
||||||
|
directories = emptyList(),
|
||||||
|
ftsRows = emptyList(),
|
||||||
|
artistCreditVersion = CURRENT_ARTIST_CREDIT_VERSION,
|
||||||
|
)
|
||||||
|
assertEquals(CURRENT_ARTIST_CREDIT_VERSION, dao.getSource("local:1")?.artistCreditVersion)
|
||||||
|
}
|
||||||
|
|
||||||
/** 10 tracks under each of A-Z, so every section has rows above and below it. */
|
/** 10 tracks under each of A-Z, so every section has rows above and below it. */
|
||||||
private fun seedAlphabet(): List<TrackEntity> =
|
private fun seedAlphabet(): List<TrackEntity> =
|
||||||
(0 until ALPHABET_SEED_SIZE).map { index ->
|
(0 until ALPHABET_SEED_SIZE).map { index ->
|
||||||
|
|||||||
+21
@@ -39,9 +39,11 @@ import expo.modules.kotlin.modules.ModuleDefinition
|
|||||||
import expo.modules.kotlin.records.Field
|
import expo.modules.kotlin.records.Field
|
||||||
import expo.modules.kotlin.records.Record
|
import expo.modules.kotlin.records.Record
|
||||||
import expo.modules.astralibraryscanner.data.AstraLibraryRepository
|
import expo.modules.astralibraryscanner.data.AstraLibraryRepository
|
||||||
|
import expo.modules.astralibraryscanner.data.ArtistCreditMetadataReader
|
||||||
import expo.modules.astralibraryscanner.data.LocalAudioFile
|
import expo.modules.astralibraryscanner.data.LocalAudioFile
|
||||||
import expo.modules.astralibraryscanner.data.LocalAudioMetadata
|
import expo.modules.astralibraryscanner.data.LocalAudioMetadata
|
||||||
import expo.modules.astralibraryscanner.data.ScanCancelledException
|
import expo.modules.astralibraryscanner.data.ScanCancelledException
|
||||||
|
import expo.modules.astralibraryscanner.data.formatArtistNames
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
@@ -651,6 +653,22 @@ class AstraLibraryScannerModule : Module() {
|
|||||||
} catch (_: Throwable) {}
|
} catch (_: Throwable) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val credits = ArtistCreditMetadataReader.read(context, uri, metadataTimeoutMs)
|
||||||
|
val artistNames = credits.artists.takeIf { it.size > 1 }.orEmpty()
|
||||||
|
val albumArtistNames = credits.albumArtists.takeIf { it.size > 1 }.orEmpty()
|
||||||
|
result["artistNames"] = artistNames
|
||||||
|
result["albumArtistNames"] = albumArtistNames
|
||||||
|
if (artistNames.isNotEmpty()) {
|
||||||
|
result["artist"] = formatArtistNames(artistNames)
|
||||||
|
} else if (result["artist"] == null && credits.artists.size == 1) {
|
||||||
|
result["artist"] = credits.artists[0]
|
||||||
|
}
|
||||||
|
if (albumArtistNames.isNotEmpty()) {
|
||||||
|
result["albumArtist"] = formatArtistNames(albumArtistNames)
|
||||||
|
} else if (result["albumArtist"] == null && credits.albumArtists.size == 1) {
|
||||||
|
result["albumArtist"] = credits.albumArtists[0]
|
||||||
|
}
|
||||||
|
|
||||||
// Header-level facts MMR can't provide (channels, bit depth) or only on
|
// Header-level facts MMR can't provide (channels, bit depth) or only on
|
||||||
// API 31+ (sample rate). Failure here is non-fatal — keep the tag data.
|
// API 31+ (sample rate). Failure here is non-fatal — keep the tag data.
|
||||||
val extractor = MediaExtractor()
|
val extractor = MediaExtractor()
|
||||||
@@ -693,8 +711,11 @@ class AstraLibraryScannerModule : Module() {
|
|||||||
ok = this["ok"] as? Boolean ?: false,
|
ok = this["ok"] as? Boolean ?: false,
|
||||||
title = this["title"] as? String,
|
title = this["title"] as? String,
|
||||||
artist = this["artist"] as? String,
|
artist = this["artist"] as? String,
|
||||||
|
artistNames = (this["artistNames"] as? List<*>)?.filterIsInstance<String>().orEmpty(),
|
||||||
album = this["album"] as? String,
|
album = this["album"] as? String,
|
||||||
albumArtist = this["albumArtist"] as? String,
|
albumArtist = this["albumArtist"] as? String,
|
||||||
|
albumArtistNames =
|
||||||
|
(this["albumArtistNames"] as? List<*>)?.filterIsInstance<String>().orEmpty(),
|
||||||
genre = this["genre"] as? String,
|
genre = this["genre"] as? String,
|
||||||
mimeType = this["mimeType"] as? String,
|
mimeType = this["mimeType"] as? String,
|
||||||
durationMs = (this["durationMs"] as? Number)?.toLong(),
|
durationMs = (this["durationMs"] as? Number)?.toLong(),
|
||||||
|
|||||||
+115
@@ -0,0 +1,115 @@
|
|||||||
|
package expo.modules.astralibraryscanner.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import com.google.android.exoplayer2.MediaItem
|
||||||
|
import com.google.android.exoplayer2.MetadataRetriever
|
||||||
|
import com.google.android.exoplayer2.metadata.flac.VorbisComment
|
||||||
|
import com.google.android.exoplayer2.metadata.id3.InternalFrame
|
||||||
|
import com.google.android.exoplayer2.metadata.id3.TextInformationFrame
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import org.json.JSONArray
|
||||||
|
|
||||||
|
internal const val CURRENT_ARTIST_CREDIT_VERSION = 2
|
||||||
|
internal const val LEGACY_ARTIST_CREDIT_VERSION = 1
|
||||||
|
|
||||||
|
internal data class ArtistCreditNames(
|
||||||
|
val artists: List<String> = emptyList(),
|
||||||
|
val albumArtists: List<String> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun normalizeArtistNames(values: Iterable<String?>): List<String> {
|
||||||
|
val result = LinkedHashMap<String, String>()
|
||||||
|
for (value in values) {
|
||||||
|
val display = MediaTagCleanup.clean(value)
|
||||||
|
?.replace(Regex("\\s+"), " ")
|
||||||
|
?.trim()
|
||||||
|
?.takeIf(String::isNotEmpty)
|
||||||
|
?: continue
|
||||||
|
val key = display.lowercase()
|
||||||
|
result.putIfAbsent(key, display)
|
||||||
|
}
|
||||||
|
return result.values.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun formatArtistNames(values: Iterable<String?>): String {
|
||||||
|
val names = normalizeArtistNames(values)
|
||||||
|
return when (names.size) {
|
||||||
|
0 -> ""
|
||||||
|
1 -> names[0]
|
||||||
|
2 -> "${names[0]} & ${names[1]}"
|
||||||
|
else -> "${names.dropLast(1).joinToString(", ")} & ${names.last()}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun serializeArtistNames(values: Iterable<String?>): String? {
|
||||||
|
val names = normalizeArtistNames(values)
|
||||||
|
return if (names.size > 1) JSONArray(names).toString() else null
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun deserializeArtistNames(value: String?): List<String> {
|
||||||
|
if (value.isNullOrBlank()) return emptyList()
|
||||||
|
return runCatching {
|
||||||
|
val json = JSONArray(value)
|
||||||
|
normalizeArtistNames((0 until json.length()).map { index -> json.optString(index, null) })
|
||||||
|
}.getOrDefault(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Order-preserving collector for multi-value artist tags. Container-specific
|
||||||
|
* metadata walkers feed raw key/value pairs here; normalization is kept pure so
|
||||||
|
* repeated Vorbis comments and repeated ID3 frames share identical behavior.
|
||||||
|
*/
|
||||||
|
internal class ArtistCreditCollector {
|
||||||
|
private val artists = mutableListOf<String?>()
|
||||||
|
private val albumArtists = mutableListOf<String?>()
|
||||||
|
|
||||||
|
fun consider(rawKey: String?, rawValue: String?) {
|
||||||
|
if (rawKey == null || rawValue == null) return
|
||||||
|
val key = rawKey.trim().uppercase().replace(Regex("[\\s_-]+"), "")
|
||||||
|
val target = when (key) {
|
||||||
|
"ARTIST", "ARTISTS", "TPE1" -> artists
|
||||||
|
"ALBUMARTIST", "ALBUMARTISTS", "TPE2" -> albumArtists
|
||||||
|
else -> null
|
||||||
|
} ?: return
|
||||||
|
|
||||||
|
// ID3 text frames may expose multiple values joined with NUL even when the
|
||||||
|
// decoder returns a single string. Do not split punctuation: commas and
|
||||||
|
// ampersands are valid parts of an individual artist name.
|
||||||
|
rawValue.split('\u0000').forEach(target::add)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun build(): ArtistCreditNames = ArtistCreditNames(
|
||||||
|
artists = normalizeArtistNames(artists),
|
||||||
|
albumArtists = normalizeArtistNames(albumArtists),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal object ArtistCreditMetadataReader {
|
||||||
|
fun read(context: Context, uri: Uri, timeoutMs: Long): ArtistCreditNames {
|
||||||
|
return try {
|
||||||
|
val trackGroups = MetadataRetriever.retrieveMetadata(context, MediaItem.fromUri(uri))
|
||||||
|
.get(timeoutMs, TimeUnit.MILLISECONDS)
|
||||||
|
val collector = ArtistCreditCollector()
|
||||||
|
|
||||||
|
for (groupIndex in 0 until trackGroups.length) {
|
||||||
|
val group = trackGroups.get(groupIndex)
|
||||||
|
for (formatIndex in 0 until group.length) {
|
||||||
|
val metadata = group.getFormat(formatIndex).metadata ?: continue
|
||||||
|
for (entryIndex in 0 until metadata.length()) {
|
||||||
|
when (val entry = metadata.get(entryIndex)) {
|
||||||
|
is VorbisComment -> collector.consider(entry.key, entry.value)
|
||||||
|
is TextInformationFrame ->
|
||||||
|
entry.values.forEach { value -> collector.consider(entry.id, value) }
|
||||||
|
is InternalFrame -> collector.consider(entry.description, entry.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
collector.build()
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
// Unsupported container, malformed tags, I/O failure, or timeout.
|
||||||
|
ArtistCreditNames()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
-4
@@ -257,6 +257,7 @@ class AstraLibraryRepository private constructor(
|
|||||||
.mapTo(hashSetOf()) { it.uri.toString() }
|
.mapTo(hashSetOf()) { it.uri.toString() }
|
||||||
val catalogDao = requireCatalog().catalogDao()
|
val catalogDao = requireCatalog().catalogDao()
|
||||||
return requireUser().userDao().getFolders().map { folder ->
|
return requireUser().userDao().getFolders().map { folder ->
|
||||||
|
val source = catalogDao.getSource(localSourceKey(folder.id))
|
||||||
mapOf(
|
mapOf(
|
||||||
"id" to folder.id.toDouble(),
|
"id" to folder.id.toDouble(),
|
||||||
"tree_uri" to folder.treeUri,
|
"tree_uri" to folder.treeUri,
|
||||||
@@ -267,6 +268,10 @@ class AstraLibraryRepository private constructor(
|
|||||||
"scan_status" to folder.lastScanStatus,
|
"scan_status" to folder.lastScanStatus,
|
||||||
"scan_error" to folder.lastScanError,
|
"scan_error" to folder.lastScanError,
|
||||||
"track_count" to catalogDao.countActiveTracksForFolder(folder.id).toDouble(),
|
"track_count" to catalogDao.countActiveTracksForFolder(folder.id).toDouble(),
|
||||||
|
"needs_metadata_reindex" to (
|
||||||
|
source != null &&
|
||||||
|
source.artistCreditVersion < CURRENT_ARTIST_CREDIT_VERSION
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -370,6 +375,9 @@ class AstraLibraryRepository private constructor(
|
|||||||
val dao = database.catalogDao()
|
val dao = database.catalogDao()
|
||||||
val sourceKey = localSourceKey(folderId)
|
val sourceKey = localSourceKey(folderId)
|
||||||
val previousSource = dao.getSource(sourceKey)
|
val previousSource = dao.getSource(sourceKey)
|
||||||
|
val effectiveFull = full ||
|
||||||
|
(previousSource != null &&
|
||||||
|
previousSource.artistCreditVersion < CURRENT_ARTIST_CREDIT_VERSION)
|
||||||
val generationId = UUID.randomUUID().toString()
|
val generationId = UUID.randomUUID().toString()
|
||||||
val startedAt = System.currentTimeMillis()
|
val startedAt = System.currentTimeMillis()
|
||||||
|
|
||||||
@@ -415,7 +423,7 @@ class AstraLibraryRepository private constructor(
|
|||||||
async(Dispatchers.IO) {
|
async(Dispatchers.IO) {
|
||||||
throwIfScanCancelled(isCancelled)
|
throwIfScanCancelled(isCancelled)
|
||||||
val old = existingByPath[file.uri]
|
val old = existingByPath[file.uri]
|
||||||
val unchanged = !full &&
|
val unchanged = !effectiveFull &&
|
||||||
old != null &&
|
old != null &&
|
||||||
old.mtime == file.lastModified &&
|
old.mtime == file.lastModified &&
|
||||||
old.size == file.size
|
old.size == file.size
|
||||||
@@ -491,6 +499,7 @@ class AstraLibraryRepository private constructor(
|
|||||||
artistTrackIndex = readModels.artistTrackIndex,
|
artistTrackIndex = readModels.artistTrackIndex,
|
||||||
directories = readModels.directories,
|
directories = readModels.directories,
|
||||||
ftsRows = readModels.ftsRows,
|
ftsRows = readModels.ftsRows,
|
||||||
|
artistCreditVersion = CURRENT_ARTIST_CREDIT_VERSION,
|
||||||
)
|
)
|
||||||
userDao.updateFolderScanState(folderId, System.currentTimeMillis(), "ready", null)
|
userDao.updateFolderScanState(folderId, System.currentTimeMillis(), "ready", null)
|
||||||
scheduleSnapshot()
|
scheduleSnapshot()
|
||||||
@@ -2502,10 +2511,27 @@ class AstraLibraryRepository private constructor(
|
|||||||
val extension = file.name.substringAfterLast('.', "")
|
val extension = file.name.substringAfterLast('.', "")
|
||||||
val title = clean(metadata.title)
|
val title = clean(metadata.title)
|
||||||
?: file.name.removeSuffix(if (extension.isEmpty()) "" else ".$extension")
|
?: file.name.removeSuffix(if (extension.isEmpty()) "" else ".$extension")
|
||||||
val artist = clean(metadata.artist) ?: "Unknown Artist"
|
val artistNames = normalizeArtistNames(metadata.artistNames)
|
||||||
|
val albumArtistNames = normalizeArtistNames(metadata.albumArtistNames)
|
||||||
|
val artist = if (artistNames.size > 1) {
|
||||||
|
formatArtistNames(artistNames)
|
||||||
|
} else {
|
||||||
|
clean(metadata.artist) ?: artistNames.firstOrNull() ?: "Unknown Artist"
|
||||||
|
}
|
||||||
val album = clean(metadata.album) ?: "Unknown Album"
|
val album = clean(metadata.album) ?: "Unknown Album"
|
||||||
val albumArtist = clean(metadata.albumArtist)
|
val albumArtist = if (albumArtistNames.size > 1) {
|
||||||
val provisional = CatalogReadModelBuilder.provisionalIdentity(album, artist, albumArtist)
|
formatArtistNames(albumArtistNames)
|
||||||
|
} else {
|
||||||
|
clean(metadata.albumArtist) ?: albumArtistNames.firstOrNull()
|
||||||
|
}
|
||||||
|
val artistNamesJson = serializeArtistNames(artistNames)
|
||||||
|
val albumArtistNamesJson = serializeArtistNames(albumArtistNames)
|
||||||
|
val provisional = CatalogReadModelBuilder.provisionalIdentity(
|
||||||
|
album,
|
||||||
|
artist,
|
||||||
|
albumArtist,
|
||||||
|
artistNamesJson,
|
||||||
|
)
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
return TrackEntity(
|
return TrackEntity(
|
||||||
generationId = generationId,
|
generationId = generationId,
|
||||||
@@ -2514,8 +2540,10 @@ class AstraLibraryRepository private constructor(
|
|||||||
folderId = folderId,
|
folderId = folderId,
|
||||||
title = title,
|
title = title,
|
||||||
artist = artist,
|
artist = artist,
|
||||||
|
artistNamesJson = artistNamesJson,
|
||||||
album = album,
|
album = album,
|
||||||
albumArtist = albumArtist,
|
albumArtist = albumArtist,
|
||||||
|
albumArtistNamesJson = albumArtistNamesJson,
|
||||||
albumIdentityKey = provisional.first,
|
albumIdentityKey = provisional.first,
|
||||||
albumDisplayArtist = provisional.second,
|
albumDisplayArtist = provisional.second,
|
||||||
duration = (metadata.durationMs ?: 0L) / 1_000.0,
|
duration = (metadata.durationMs ?: 0L) / 1_000.0,
|
||||||
@@ -2739,6 +2767,7 @@ class AstraLibraryRepository private constructor(
|
|||||||
private fun buildCatalogDatabase(): AstraCatalogDatabase =
|
private fun buildCatalogDatabase(): AstraCatalogDatabase =
|
||||||
Room.databaseBuilder(applicationContext, AstraCatalogDatabase::class.java, CATALOG_DB_NAME)
|
Room.databaseBuilder(applicationContext, AstraCatalogDatabase::class.java, CATALOG_DB_NAME)
|
||||||
.setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING)
|
.setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING)
|
||||||
|
.addMigrations(CATALOG_MIGRATION_1_2)
|
||||||
.fallbackToDestructiveMigration(true)
|
.fallbackToDestructiveMigration(true)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
|
|||||||
+22
-2
@@ -9,7 +9,9 @@ import androidx.room.RawQuery
|
|||||||
import androidx.room.RoomDatabase
|
import androidx.room.RoomDatabase
|
||||||
import androidx.room.Transaction
|
import androidx.room.Transaction
|
||||||
import androidx.room.Upsert
|
import androidx.room.Upsert
|
||||||
|
import androidx.room.migration.Migration
|
||||||
import androidx.sqlite.db.SupportSQLiteQuery
|
import androidx.sqlite.db.SupportSQLiteQuery
|
||||||
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
|
|
||||||
data class TrackSyncRow(
|
data class TrackSyncRow(
|
||||||
val path: String,
|
val path: String,
|
||||||
@@ -87,6 +89,7 @@ interface CatalogDao {
|
|||||||
"""
|
"""
|
||||||
UPDATE catalog_sources
|
UPDATE catalog_sources
|
||||||
SET active_generation_id = :generationId,
|
SET active_generation_id = :generationId,
|
||||||
|
artist_credit_version = COALESCE(:artistCreditVersion, artist_credit_version),
|
||||||
updated_at = :updatedAt
|
updated_at = :updatedAt
|
||||||
WHERE source_key = :sourceKey
|
WHERE source_key = :sourceKey
|
||||||
""",
|
""",
|
||||||
@@ -95,6 +98,7 @@ interface CatalogDao {
|
|||||||
sourceKey: String,
|
sourceKey: String,
|
||||||
generationId: String,
|
generationId: String,
|
||||||
updatedAt: Long,
|
updatedAt: Long,
|
||||||
|
artistCreditVersion: Int? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.ABORT)
|
@Insert(onConflict = OnConflictStrategy.ABORT)
|
||||||
@@ -1206,11 +1210,12 @@ interface CatalogDao {
|
|||||||
artistTrackIndex: List<ArtistTrackIndexEntity>,
|
artistTrackIndex: List<ArtistTrackIndexEntity>,
|
||||||
directories: List<DirectorySummaryEntity>,
|
directories: List<DirectorySummaryEntity>,
|
||||||
ftsRows: List<TrackFtsEntity>,
|
ftsRows: List<TrackFtsEntity>,
|
||||||
|
artistCreditVersion: Int? = null,
|
||||||
): Long {
|
): Long {
|
||||||
for (update in albumIdentityUpdates) {
|
for (update in albumIdentityUpdates) {
|
||||||
updateAlbumIdentity(update.trackId, update.identityKey, update.displayArtist)
|
updateAlbumIdentity(update.trackId, update.identityKey, update.displayArtist)
|
||||||
}
|
}
|
||||||
setActiveGeneration(sourceKey, generationId, now)
|
setActiveGeneration(sourceKey, generationId, now, artistCreditVersion)
|
||||||
setGenerationState(generationId, "active", now, null)
|
setGenerationState(generationId, "active", now, null)
|
||||||
incrementRevision(now)
|
incrementRevision(now)
|
||||||
val revision = getRevision()
|
val revision = getRevision()
|
||||||
@@ -1283,9 +1288,24 @@ interface CatalogDao {
|
|||||||
TrackFtsEntity::class,
|
TrackFtsEntity::class,
|
||||||
],
|
],
|
||||||
views = [ActiveTrackView::class],
|
views = [ActiveTrackView::class],
|
||||||
version = 1,
|
version = 2,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
)
|
)
|
||||||
abstract class AstraCatalogDatabase : RoomDatabase() {
|
abstract class AstraCatalogDatabase : RoomDatabase() {
|
||||||
abstract fun catalogDao(): CatalogDao
|
abstract fun catalogDao(): CatalogDao
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal val CATALOG_MIGRATION_1_2 = object : Migration(1, 2) {
|
||||||
|
override fun migrate(database: SupportSQLiteDatabase) {
|
||||||
|
database.execSQL("ALTER TABLE tracks ADD COLUMN artist_names_json TEXT")
|
||||||
|
database.execSQL("ALTER TABLE tracks ADD COLUMN album_artist_names_json TEXT")
|
||||||
|
database.execSQL(
|
||||||
|
"ALTER TABLE catalog_sources ADD COLUMN artist_credit_version INTEGER NOT NULL DEFAULT 2",
|
||||||
|
)
|
||||||
|
// Existing local generations used the singular Android metadata fields and
|
||||||
|
// require a complete extraction pass. Remote rows need no forced network sync.
|
||||||
|
database.execSQL(
|
||||||
|
"UPDATE catalog_sources SET artist_credit_version = 1 WHERE source_type = 'local'",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+6
@@ -25,6 +25,8 @@ data class CatalogSourceEntity(
|
|||||||
@ColumnInfo(name = "source_id") val sourceId: Long,
|
@ColumnInfo(name = "source_id") val sourceId: Long,
|
||||||
@ColumnInfo(name = "active_generation_id") val activeGenerationId: String? = null,
|
@ColumnInfo(name = "active_generation_id") val activeGenerationId: String? = null,
|
||||||
@ColumnInfo(name = "updated_at") val updatedAt: Long,
|
@ColumnInfo(name = "updated_at") val updatedAt: Long,
|
||||||
|
@ColumnInfo(name = "artist_credit_version", defaultValue = "2")
|
||||||
|
val artistCreditVersion: Int = CURRENT_ARTIST_CREDIT_VERSION,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
@@ -60,8 +62,10 @@ data class TrackEntity(
|
|||||||
@ColumnInfo(name = "folder_id") val folderId: Long? = null,
|
@ColumnInfo(name = "folder_id") val folderId: Long? = null,
|
||||||
val title: String,
|
val title: String,
|
||||||
val artist: String,
|
val artist: String,
|
||||||
|
@ColumnInfo(name = "artist_names_json") val artistNamesJson: String? = null,
|
||||||
val album: String,
|
val album: String,
|
||||||
@ColumnInfo(name = "album_artist") val albumArtist: String? = null,
|
@ColumnInfo(name = "album_artist") val albumArtist: String? = null,
|
||||||
|
@ColumnInfo(name = "album_artist_names_json") val albumArtistNamesJson: String? = null,
|
||||||
@ColumnInfo(name = "album_identity_key") val albumIdentityKey: String,
|
@ColumnInfo(name = "album_identity_key") val albumIdentityKey: String,
|
||||||
@ColumnInfo(name = "album_display_artist") val albumDisplayArtist: String? = null,
|
@ColumnInfo(name = "album_display_artist") val albumDisplayArtist: String? = null,
|
||||||
val duration: Double = 0.0,
|
val duration: Double = 0.0,
|
||||||
@@ -123,8 +127,10 @@ data class ActiveTrackView(
|
|||||||
@ColumnInfo(name = "folder_id") val folderId: Long?,
|
@ColumnInfo(name = "folder_id") val folderId: Long?,
|
||||||
val title: String,
|
val title: String,
|
||||||
val artist: String,
|
val artist: String,
|
||||||
|
@ColumnInfo(name = "artist_names_json") val artistNamesJson: String?,
|
||||||
val album: String,
|
val album: String,
|
||||||
@ColumnInfo(name = "album_artist") val albumArtist: String?,
|
@ColumnInfo(name = "album_artist") val albumArtist: String?,
|
||||||
|
@ColumnInfo(name = "album_artist_names_json") val albumArtistNamesJson: String?,
|
||||||
@ColumnInfo(name = "album_identity_key") val albumIdentityKey: String,
|
@ColumnInfo(name = "album_identity_key") val albumIdentityKey: String,
|
||||||
@ColumnInfo(name = "album_display_artist") val albumDisplayArtist: String?,
|
@ColumnInfo(name = "album_display_artist") val albumDisplayArtist: String?,
|
||||||
val duration: Double,
|
val duration: Double,
|
||||||
|
|||||||
+26
-7
@@ -33,8 +33,10 @@ data class LocalAudioMetadata(
|
|||||||
val ok: Boolean,
|
val ok: Boolean,
|
||||||
val title: String? = null,
|
val title: String? = null,
|
||||||
val artist: String? = null,
|
val artist: String? = null,
|
||||||
|
val artistNames: List<String> = emptyList(),
|
||||||
val album: String? = null,
|
val album: String? = null,
|
||||||
val albumArtist: String? = null,
|
val albumArtist: String? = null,
|
||||||
|
val albumArtistNames: List<String> = emptyList(),
|
||||||
val genre: String? = null,
|
val genre: String? = null,
|
||||||
val mimeType: String? = null,
|
val mimeType: String? = null,
|
||||||
val durationMs: Long? = null,
|
val durationMs: Long? = null,
|
||||||
@@ -128,13 +130,14 @@ object CatalogReadModelBuilder {
|
|||||||
album: String,
|
album: String,
|
||||||
artist: String,
|
artist: String,
|
||||||
albumArtist: String?,
|
albumArtist: String?,
|
||||||
|
artistNamesJson: String? = null,
|
||||||
): Pair<String, String> {
|
): Pair<String, String> {
|
||||||
val albumKey = normalizeKey(normalizeAlbum(album))
|
val albumKey = normalizeKey(normalizeAlbum(album))
|
||||||
val explicit = normalizeDisplay(albumArtist.orEmpty())
|
val explicit = normalizeDisplay(albumArtist.orEmpty())
|
||||||
if (explicit.isNotEmpty()) {
|
if (explicit.isNotEmpty()) {
|
||||||
return identity(albumKey, "aa:${normalizeKey(explicit).ifEmpty { normalizeKey(UNKNOWN_ARTIST) }}") to explicit
|
return identity(albumKey, "aa:${normalizeKey(explicit).ifEmpty { normalizeKey(UNKNOWN_ARTIST) }}") to explicit
|
||||||
}
|
}
|
||||||
val primary = primaryArtist(artist)
|
val primary = primaryArtist(artist, artistNamesJson)
|
||||||
return identity(albumKey, "ta:${normalizeKey(primary)}") to primary
|
return identity(albumKey, "ta:${normalizeKey(primary)}") to primary
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,7 +148,7 @@ object CatalogReadModelBuilder {
|
|||||||
for (track in tracks) {
|
for (track in tracks) {
|
||||||
val albumKey = normalizeKey(normalizeAlbum(track.album))
|
val albumKey = normalizeKey(normalizeAlbum(track.album))
|
||||||
val explicit = normalizeDisplay(track.albumArtist.orEmpty())
|
val explicit = normalizeDisplay(track.albumArtist.orEmpty())
|
||||||
val primary = primaryArtist(track.artist)
|
val primary = primaryArtist(track.artist, track.artistNamesJson)
|
||||||
val prepared = PreparedAlbumTrack(
|
val prepared = PreparedAlbumTrack(
|
||||||
track = track,
|
track = track,
|
||||||
albumKey = albumKey,
|
albumKey = albumKey,
|
||||||
@@ -408,9 +411,11 @@ object CatalogReadModelBuilder {
|
|||||||
private fun canonicalPrimary(track: TrackEntity): String {
|
private fun canonicalPrimary(track: TrackEntity): String {
|
||||||
val albumArtist = normalizeDisplay(track.albumArtist.orEmpty())
|
val albumArtist = normalizeDisplay(track.albumArtist.orEmpty())
|
||||||
if (albumArtist.isNotEmpty()) {
|
if (albumArtist.isNotEmpty()) {
|
||||||
|
val parsedAlbumArtists = deserializeArtistNames(track.albumArtistNamesJson)
|
||||||
|
if (parsedAlbumArtists.isNotEmpty()) return parsedAlbumArtists[0]
|
||||||
return splitAlbumArtists(albumArtist).firstOrNull() ?: albumArtist
|
return splitAlbumArtists(albumArtist).firstOrNull() ?: albumArtist
|
||||||
}
|
}
|
||||||
return splitTrackArtists(track.artist).firstOrNull() ?: UNKNOWN_ARTIST
|
return primaryArtist(track.artist, track.artistNamesJson)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun canonicalArtistNames(track: TrackEntity): List<String> {
|
private fun canonicalArtistNames(track: TrackEntity): List<String> {
|
||||||
@@ -421,9 +426,21 @@ object CatalogReadModelBuilder {
|
|||||||
if (key.isNotEmpty()) result.putIfAbsent(key, display)
|
if (key.isNotEmpty()) result.putIfAbsent(key, display)
|
||||||
}
|
}
|
||||||
add(canonicalPrimary(track))
|
add(canonicalPrimary(track))
|
||||||
val trackArtists = splitTrackArtists(track.artist)
|
val parsedTrackArtists = deserializeArtistNames(track.artistNamesJson)
|
||||||
|
val trackArtists = if (parsedTrackArtists.isNotEmpty()) {
|
||||||
|
parsedTrackArtists
|
||||||
|
} else {
|
||||||
|
splitTrackArtists(track.artist)
|
||||||
|
}
|
||||||
trackArtists.forEach(::add)
|
trackArtists.forEach(::add)
|
||||||
if (trackArtists.isEmpty()) splitAlbumArtists(track.albumArtist.orEmpty()).forEach(::add)
|
if (trackArtists.isEmpty()) {
|
||||||
|
val parsedAlbumArtists = deserializeArtistNames(track.albumArtistNamesJson)
|
||||||
|
if (parsedAlbumArtists.isNotEmpty()) {
|
||||||
|
parsedAlbumArtists.forEach(::add)
|
||||||
|
} else {
|
||||||
|
splitAlbumArtists(track.albumArtist.orEmpty()).forEach(::add)
|
||||||
|
}
|
||||||
|
}
|
||||||
return result.values.toList()
|
return result.values.toList()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -480,8 +497,10 @@ object CatalogReadModelBuilder {
|
|||||||
private fun normalizeAlbum(value: String): String =
|
private fun normalizeAlbum(value: String): String =
|
||||||
normalizeDisplay(value).ifEmpty { UNKNOWN_ALBUM }
|
normalizeDisplay(value).ifEmpty { UNKNOWN_ALBUM }
|
||||||
|
|
||||||
private fun primaryArtist(value: String): String =
|
private fun primaryArtist(value: String, artistNamesJson: String? = null): String =
|
||||||
splitTrackArtists(value).firstOrNull() ?: UNKNOWN_ARTIST
|
deserializeArtistNames(artistNamesJson).firstOrNull()
|
||||||
|
?: splitTrackArtists(value).firstOrNull()
|
||||||
|
?: UNKNOWN_ARTIST
|
||||||
|
|
||||||
private fun identity(albumKey: String, discriminator: String): String =
|
private fun identity(albumKey: String, discriminator: String): String =
|
||||||
"album:$albumKey::$discriminator"
|
"album:$albumKey::$discriminator"
|
||||||
|
|||||||
+2
@@ -133,8 +133,10 @@ fun ActiveTrackView.toBridgeMap(): Map<String, Any?> = mapOf(
|
|||||||
"folder_id" to folderId?.toDouble(),
|
"folder_id" to folderId?.toDouble(),
|
||||||
"title" to title,
|
"title" to title,
|
||||||
"artist" to artist,
|
"artist" to artist,
|
||||||
|
"artist_names" to deserializeArtistNames(artistNamesJson),
|
||||||
"album" to album,
|
"album" to album,
|
||||||
"album_artist" to albumArtist,
|
"album_artist" to albumArtist,
|
||||||
|
"album_artist_names" to deserializeArtistNames(albumArtistNamesJson),
|
||||||
"album_identity_key" to albumIdentityKey,
|
"album_identity_key" to albumIdentityKey,
|
||||||
"album_display_artist" to albumDisplayArtist,
|
"album_display_artist" to albumDisplayArtist,
|
||||||
"duration" to duration,
|
"duration" to duration,
|
||||||
|
|||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
package expo.modules.astralibraryscanner.data
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ArtistCreditsTest {
|
||||||
|
@Test
|
||||||
|
fun repeatedVorbisCreditsPreserveOrderAndPunctuation() {
|
||||||
|
val collector = ArtistCreditCollector()
|
||||||
|
collector.consider("ARTIST", " Earth, Wind & Fire ")
|
||||||
|
collector.consider("artist", "The Emotions")
|
||||||
|
collector.consider("ALBUMARTIST", "Curator One")
|
||||||
|
collector.consider("album_artist", "Curator Two")
|
||||||
|
|
||||||
|
val credits = collector.build()
|
||||||
|
|
||||||
|
assertEquals(listOf("Earth, Wind & Fire", "The Emotions"), credits.artists)
|
||||||
|
assertEquals(listOf("Curator One", "Curator Two"), credits.albumArtists)
|
||||||
|
assertEquals("Earth, Wind & Fire & The Emotions", formatArtistNames(credits.artists))
|
||||||
|
assertEquals("Curator One & Curator Two", formatArtistNames(credits.albumArtists))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun duplicateCreditsAreRemovedCaseInsensitivelyWithoutReordering() {
|
||||||
|
val collector = ArtistCreditCollector()
|
||||||
|
collector.consider("TPE1", "Artist One")
|
||||||
|
collector.consider("ARTIST", " artist one ")
|
||||||
|
collector.consider("ARTISTS", "Artist Two")
|
||||||
|
collector.consider("TPE2", "Album Artist\u0000Guest Curator")
|
||||||
|
|
||||||
|
val credits = collector.build()
|
||||||
|
|
||||||
|
assertEquals(listOf("Artist One", "Artist Two"), credits.artists)
|
||||||
|
assertEquals(listOf("Album Artist", "Guest Curator"), credits.albumArtists)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun unrelatedOrMissingMetadataProducesEmptyCredits() {
|
||||||
|
val collector = ArtistCreditCollector()
|
||||||
|
collector.consider("TITLE", "Song")
|
||||||
|
collector.consider(null, "Artist")
|
||||||
|
collector.consider("ARTIST", null)
|
||||||
|
|
||||||
|
assertEquals(ArtistCreditNames(), collector.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -24,8 +24,12 @@ export interface ExtractedMetadata {
|
|||||||
error?: string;
|
error?: string;
|
||||||
title?: string | null;
|
title?: string | null;
|
||||||
artist?: string | null;
|
artist?: string | null;
|
||||||
|
/** Ordered repeated ARTIST tag values; empty when the file has no multi-value credit. */
|
||||||
|
artistNames?: string[];
|
||||||
album?: string | null;
|
album?: string | null;
|
||||||
albumArtist?: string | null;
|
albumArtist?: string | null;
|
||||||
|
/** Ordered repeated ALBUMARTIST tag values; empty when not multi-valued. */
|
||||||
|
albumArtistNames?: string[];
|
||||||
genre?: string | null;
|
genre?: string | null;
|
||||||
/** Container mime type reported by MediaMetadataRetriever. */
|
/** Container mime type reported by MediaMetadataRetriever. */
|
||||||
mimeType?: string | null;
|
mimeType?: string | null;
|
||||||
|
|||||||
+1
-1
@@ -68,7 +68,7 @@
|
|||||||
"test:desktop-remote": "node --experimental-strip-types --test src/services/desktopRemotePairing.test.mts",
|
"test:desktop-remote": "node --experimental-strip-types --test src/services/desktopRemotePairing.test.mts",
|
||||||
"test:dynamic-playlists": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/shared/playlists/dynamicPlaylist.test.mts",
|
"test:dynamic-playlists": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/shared/playlists/dynamicPlaylist.test.mts",
|
||||||
"test:album-grouping": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/shared/library/albumGrouping.test.mts src/shared/library/albumEligibility.test.mts src/library/albumIdentity.test.mts src/library/albumSummary.test.mts",
|
"test:album-grouping": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/shared/library/albumGrouping.test.mts src/shared/library/albumEligibility.test.mts src/library/albumIdentity.test.mts src/library/albumSummary.test.mts",
|
||||||
"test:artist-grouping": "node --experimental-strip-types --test src/library/artistGrouping.test.mts",
|
"test:artist-grouping": "node --experimental-strip-types --test src/library/artistGrouping.test.mts src/audio/artistCreditTransport.test.mts",
|
||||||
"test:waveform-math": "node --experimental-strip-types --test src/scope/waveformMath.test.mts",
|
"test:waveform-math": "node --experimental-strip-types --test src/scope/waveformMath.test.mts",
|
||||||
"test:desktop-sync": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/library/importMatching.test.mts src/services/desktopSyncPlaylistMerge.test.mts src/services/desktopSyncPolicy.test.mts src/shared/sync/conflictPreview.test.mts",
|
"test:desktop-sync": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/library/importMatching.test.mts src/services/desktopSyncPlaylistMerge.test.mts src/services/desktopSyncPolicy.test.mts src/shared/sync/conflictPreview.test.mts",
|
||||||
"test:eq-share": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/audio/eqShare.test.mts",
|
"test:eq-share": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/audio/eqShare.test.mts",
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
parseArtistCreditTransport,
|
||||||
|
serializeArtistCreditTransport,
|
||||||
|
} from './artistCreditTransport.ts';
|
||||||
|
|
||||||
|
test('artist credits survive the string-only player transport', () => {
|
||||||
|
const names = ['Earth, Wind & Fire', 'The Emotions'];
|
||||||
|
assert.deepEqual(
|
||||||
|
parseArtistCreditTransport(serializeArtistCreditTransport(names)),
|
||||||
|
names
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('artist credit transport rejects malformed or empty values', () => {
|
||||||
|
assert.equal(parseArtistCreditTransport('{bad json'), undefined);
|
||||||
|
assert.equal(parseArtistCreditTransport('[]'), undefined);
|
||||||
|
assert.equal(serializeArtistCreditTransport([]), undefined);
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { normalizeArtistNames } from '../shared/library/artistCredits.ts';
|
||||||
|
|
||||||
|
export function serializeArtistCreditTransport(
|
||||||
|
names: readonly unknown[] | null | undefined
|
||||||
|
): string | undefined {
|
||||||
|
const normalized = normalizeArtistNames(names);
|
||||||
|
return normalized.length > 0 ? JSON.stringify(normalized) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseArtistCreditTransport(value: unknown): string[] | undefined {
|
||||||
|
if (typeof value !== 'string' || value.trim().length === 0) return undefined;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value) as unknown;
|
||||||
|
if (!Array.isArray(parsed)) return undefined;
|
||||||
|
const names = normalizeArtistNames(parsed);
|
||||||
|
return names.length > 0 ? names : undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,10 @@ import type { Track as RntpTrack } from 'react-native-track-player';
|
|||||||
import type { Track } from '@/types/audio';
|
import type { Track } from '@/types/audio';
|
||||||
import { streamUrlForTrack } from '@/services/remoteUrls';
|
import { streamUrlForTrack } from '@/services/remoteUrls';
|
||||||
import { artworkThumbFromSource, playerBackdropArtworkSource } from '@/library/artwork';
|
import { artworkThumbFromSource, playerBackdropArtworkSource } from '@/library/artwork';
|
||||||
|
import {
|
||||||
|
parseArtistCreditTransport,
|
||||||
|
serializeArtistCreditTransport,
|
||||||
|
} from './artistCreditTransport';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* M0 verification tracks. Streamed from a public royalty-free source so playback
|
* M0 verification tracks. Streamed from a public royalty-free source so playback
|
||||||
@@ -64,6 +68,10 @@ export function toRntpTrack(track: Track): RntpTrack {
|
|||||||
sampleRate: track.sampleRate,
|
sampleRate: track.sampleRate,
|
||||||
bitDepth: track.bitDepth,
|
bitDepth: track.bitDepth,
|
||||||
bitrate: track.bitrate,
|
bitrate: track.bitrate,
|
||||||
|
astraArtistNamesJson: serializeArtistCreditTransport(track.artistNames),
|
||||||
|
astraAlbumArtist: track.albumArtist,
|
||||||
|
astraAlbumArtistNamesJson: serializeArtistCreditTransport(track.albumArtistNames),
|
||||||
|
astraAlbumIdentityKey: track.albumIdentityKey,
|
||||||
astraPath: track.path,
|
astraPath: track.path,
|
||||||
sourceType: track.sourceType,
|
sourceType: track.sourceType,
|
||||||
sourceId: track.sourceId,
|
sourceId: track.sourceId,
|
||||||
@@ -81,7 +89,13 @@ export function rntpToTrack(rt: RntpTrack): Track {
|
|||||||
path: astraPath ?? String(rt.url),
|
path: astraPath ?? String(rt.url),
|
||||||
title: rt.title ?? 'Unknown title',
|
title: rt.title ?? 'Unknown title',
|
||||||
artist: rt.artist ?? 'Unknown artist',
|
artist: rt.artist ?? 'Unknown artist',
|
||||||
|
artistNames: parseArtistCreditTransport(rt.astraArtistNamesJson),
|
||||||
album: rt.album ?? '',
|
album: rt.album ?? '',
|
||||||
|
albumArtist:
|
||||||
|
typeof rt.astraAlbumArtist === 'string' ? rt.astraAlbumArtist : undefined,
|
||||||
|
albumArtistNames: parseArtistCreditTransport(rt.astraAlbumArtistNamesJson),
|
||||||
|
albumIdentityKey:
|
||||||
|
typeof rt.astraAlbumIdentityKey === 'string' ? rt.astraAlbumIdentityKey : undefined,
|
||||||
duration: typeof rt.duration === 'number' ? rt.duration : 0,
|
duration: typeof rt.duration === 'number' ? rt.duration : 0,
|
||||||
artworkData:
|
artworkData:
|
||||||
typeof rt.astraArtworkData === 'string'
|
typeof rt.astraArtworkData === 'string'
|
||||||
|
|||||||
@@ -262,20 +262,23 @@ export function NowPlayingOverlay() {
|
|||||||
[libraryTracks, track]
|
[libraryTracks, track]
|
||||||
);
|
);
|
||||||
const artistName = track
|
const artistName = track
|
||||||
? resolveNavigationArtist(
|
? resolveNavigationArtist(
|
||||||
libraryTrack ?? { artist: track.artist, album_artist: track.albumArtist ?? null },
|
libraryTrack ?? {
|
||||||
|
artist: track.artist,
|
||||||
|
artist_names: track.artistNames,
|
||||||
|
album_artist: track.albumArtist ?? null,
|
||||||
|
album_artist_names: track.albumArtistNames,
|
||||||
|
},
|
||||||
artistGroupingMode
|
artistGroupingMode
|
||||||
)
|
)
|
||||||
: '';
|
: '';
|
||||||
const artistCreditTokens = useMemo(() => {
|
const artistCreditTokens = useMemo(() => {
|
||||||
if (!track) return [];
|
if (!track) return [];
|
||||||
const collaborators = splitCollaborators(track.artist);
|
const collaborators =
|
||||||
return buildArtistNameTokens(
|
track.artistNames && track.artistNames.length > 0
|
||||||
collaborators.length > 0 ? collaborators : [track.artist]
|
? track.artistNames
|
||||||
).map((token) => ({
|
: splitCollaborators(track.artist);
|
||||||
...token,
|
return buildArtistNameTokens(collaborators.length > 0 ? collaborators : [track.artist]);
|
||||||
separator: token.separator ? ', ' : null,
|
|
||||||
}));
|
|
||||||
}, [track]);
|
}, [track]);
|
||||||
const albumKey = track?.albumIdentityKey ?? libraryTrack?.album_identity_key;
|
const albumKey = track?.albumIdentityKey ?? libraryTrack?.album_identity_key;
|
||||||
|
|
||||||
|
|||||||
@@ -24,3 +24,10 @@ test('builds separate clickable credits for collaborative track artists', () =>
|
|||||||
{ artist: 'ValkyR', separator: null },
|
{ artist: 'ValkyR', separator: null },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('structured credits preserve commas and ampersands inside one artist name', () => {
|
||||||
|
assert.deepEqual(buildArtistNameTokens(['Earth, Wind & Fire', 'The Emotions']), [
|
||||||
|
{ artist: 'Earth, Wind & Fire', separator: ' & ' },
|
||||||
|
{ artist: 'The Emotions', separator: null },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ function createRow(
|
|||||||
id: overrides.id ?? nextId++,
|
id: overrides.id ?? nextId++,
|
||||||
album: overrides.album,
|
album: overrides.album,
|
||||||
artist: overrides.artist,
|
artist: overrides.artist,
|
||||||
|
artist_names: overrides.artist_names ?? [],
|
||||||
album_artist: overrides.album_artist ?? null,
|
album_artist: overrides.album_artist ?? null,
|
||||||
|
album_artist_names: overrides.album_artist_names ?? [],
|
||||||
artwork_hash: overrides.artwork_hash ?? null,
|
artwork_hash: overrides.artwork_hash ?? null,
|
||||||
source_type: overrides.source_type ?? 'local',
|
source_type: overrides.source_type ?? 'local',
|
||||||
artwork_source_id: overrides.artwork_source_id ?? null,
|
artwork_source_id: overrides.artwork_source_id ?? null,
|
||||||
@@ -41,6 +43,17 @@ test('provisional identity uses the primary collaborator when album artist is mi
|
|||||||
assert.equal(provisional.displayArtist, 'Jane Remover');
|
assert.equal(provisional.displayArtist, 'Jane Remover');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('provisional identity prefers the first structured artist credit', () => {
|
||||||
|
const provisional = buildProvisionalAlbumIdentity(
|
||||||
|
null,
|
||||||
|
'Earth, Wind & Fire & The Emotions',
|
||||||
|
'duets',
|
||||||
|
['Earth, Wind & Fire', 'The Emotions']
|
||||||
|
);
|
||||||
|
assert.equal(provisional.key, 'album:duets::ta:earth, wind & fire');
|
||||||
|
assert.equal(provisional.displayArtist, 'Earth, Wind & Fire');
|
||||||
|
});
|
||||||
|
|
||||||
test('recompute merges shared-cover multi-artist albums into a Various Artists group', () => {
|
test('recompute merges shared-cover multi-artist albums into a Various Artists group', () => {
|
||||||
const rows = [
|
const rows = [
|
||||||
createRow({ id: 1, album: 'Split Release', artist: 'Artist A', artwork_hash: 'shared' }),
|
createRow({ id: 1, album: 'Split Release', artist: 'Artist A', artwork_hash: 'shared' }),
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
groupTracksByAlbumIdentity,
|
groupTracksByAlbumIdentity,
|
||||||
normalizeDisplay,
|
normalizeDisplay,
|
||||||
} from '../shared/library/albumGrouping.ts';
|
} from '../shared/library/albumGrouping.ts';
|
||||||
|
import { formatArtistNames, normalizeArtistNames } from '../shared/library/artistCredits.ts';
|
||||||
|
|
||||||
export interface ProvisionalAlbumIdentity {
|
export interface ProvisionalAlbumIdentity {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -32,11 +33,23 @@ export interface ProvisionalAlbumIdentity {
|
|||||||
export function buildProvisionalAlbumIdentity(
|
export function buildProvisionalAlbumIdentity(
|
||||||
albumArtist: string | null,
|
albumArtist: string | null,
|
||||||
artist: string,
|
artist: string,
|
||||||
album: string
|
album: string,
|
||||||
|
artistNames?: readonly string[] | null,
|
||||||
|
albumArtistNames?: readonly string[] | null
|
||||||
): ProvisionalAlbumIdentity {
|
): ProvisionalAlbumIdentity {
|
||||||
const key = buildAlbumIdentityKeyFromTrack({ album, artist, album_artist: albumArtist });
|
const key = buildAlbumIdentityKeyFromTrack({
|
||||||
const normalizedAlbumArtist = normalizeDisplay(albumArtist ?? '');
|
album,
|
||||||
const displayArtist = normalizedAlbumArtist || getPrimaryArtistFromTrackArtist(artist);
|
artist,
|
||||||
|
artist_names: artistNames,
|
||||||
|
album_artist: albumArtist,
|
||||||
|
album_artist_names: albumArtistNames,
|
||||||
|
});
|
||||||
|
const normalizedAlbumArtist =
|
||||||
|
normalizeDisplay(albumArtist ?? '') || formatArtistNames(albumArtistNames);
|
||||||
|
const displayArtist =
|
||||||
|
normalizedAlbumArtist ||
|
||||||
|
normalizeArtistNames(artistNames)[0] ||
|
||||||
|
getPrimaryArtistFromTrackArtist(artist);
|
||||||
return { key, displayArtist };
|
return { key, displayArtist };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,7 +58,9 @@ export interface AlbumIdentityRow {
|
|||||||
id: number;
|
id: number;
|
||||||
album: string;
|
album: string;
|
||||||
artist: string;
|
artist: string;
|
||||||
|
artist_names: string[];
|
||||||
album_artist: string | null;
|
album_artist: string | null;
|
||||||
|
album_artist_names: string[];
|
||||||
artwork_hash: string | null;
|
artwork_hash: string | null;
|
||||||
source_type: string;
|
source_type: string;
|
||||||
artwork_source_id: string | null;
|
artwork_source_id: string | null;
|
||||||
@@ -72,7 +87,9 @@ export function computeAlbumIdentityUpdates(
|
|||||||
row,
|
row,
|
||||||
album: row.album,
|
album: row.album,
|
||||||
artist: row.artist,
|
artist: row.artist,
|
||||||
|
artist_names: row.artist_names,
|
||||||
album_artist: row.album_artist,
|
album_artist: row.album_artist,
|
||||||
|
album_artist_names: row.album_artist_names,
|
||||||
base_artwork_hash:
|
base_artwork_hash:
|
||||||
row.artwork_hash ?? (row.source_type !== 'local' ? row.artwork_source_id : null),
|
row.artwork_hash ?? (row.source_type !== 'local' ? row.artwork_source_id : null),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ function createTrack(
|
|||||||
const timestamp = nextTimestamp++;
|
const timestamp = nextTimestamp++;
|
||||||
return {
|
return {
|
||||||
artist: overrides.artist,
|
artist: overrides.artist,
|
||||||
|
artist_names: overrides.artist_names ?? null,
|
||||||
album_artist: overrides.album_artist ?? null,
|
album_artist: overrides.album_artist ?? null,
|
||||||
|
album_artist_names: overrides.album_artist_names ?? null,
|
||||||
artwork_hash: overrides.artwork_hash ?? null,
|
artwork_hash: overrides.artwork_hash ?? null,
|
||||||
year: overrides.year ?? null,
|
year: overrides.year ?? null,
|
||||||
added_at: overrides.added_at ?? timestamp,
|
added_at: overrides.added_at ?? timestamp,
|
||||||
@@ -40,6 +42,37 @@ test('canonical artist records distinguish primary and collaborator-only artists
|
|||||||
assert.equal(guest.primary_track_count, 0);
|
assert.equal(guest.primary_track_count, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('structured credits keep punctuation inside an artist name', () => {
|
||||||
|
const artists = buildArtistList([
|
||||||
|
createTrack({
|
||||||
|
artist: 'Earth, Wind & Fire & The Emotions',
|
||||||
|
artist_names: ['Earth, Wind & Fire', 'The Emotions'],
|
||||||
|
}),
|
||||||
|
], 'astra');
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
artists.map((artist) => [artist.artist, artist.primary_track_count]),
|
||||||
|
[
|
||||||
|
['Earth, Wind & Fire', 1],
|
||||||
|
['The Emotions', 0],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert.ok(!artists.some((artist) => artist.artist === 'Earth'));
|
||||||
|
assert.ok(!artists.some((artist) => artist.artist === 'Wind'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('file-tags mode keeps a structured collaboration as one display group', () => {
|
||||||
|
const display = 'Earth, Wind & Fire & The Emotions';
|
||||||
|
const artists = buildArtistList([
|
||||||
|
createTrack({
|
||||||
|
artist: display,
|
||||||
|
artist_names: ['Earth, Wind & Fire', 'The Emotions'],
|
||||||
|
}),
|
||||||
|
], 'fileTags');
|
||||||
|
|
||||||
|
assert.deepEqual(artists.map((artist) => artist.artist), [display]);
|
||||||
|
});
|
||||||
|
|
||||||
test('file-tags artist records count every indexed track as primary', () => {
|
test('file-tags artist records count every indexed track as primary', () => {
|
||||||
const artists = buildArtistList([
|
const artists = buildArtistList([
|
||||||
createTrack({ artist: 'Primary Artist feat. Guest Artist' }),
|
createTrack({ artist: 'Primary Artist feat. Guest Artist' }),
|
||||||
|
|||||||
@@ -5,12 +5,13 @@
|
|||||||
// every collaborator so featured artists are browsable.
|
// every collaborator so featured artists are browsable.
|
||||||
// 'fileTags' (desktop "strict"): use the tag verbatim (album_artist || artist).
|
// 'fileTags' (desktop "strict"): use the tag verbatim (album_artist || artist).
|
||||||
//
|
//
|
||||||
// Mobile has no parsed `artist_names_json` columns (MMR yields one artist string),
|
// Structured artist arrays are preferred when present so punctuation inside an
|
||||||
// so desktop's parsed-array paths collapse to splitCollaborators(artist) — which is
|
// individual name is never mistaken for a collaboration boundary. Legacy and
|
||||||
// the parsing heuristic. Everything here is derivable from artist + album_artist.
|
// remote rows still fall back to the original display-string heuristics.
|
||||||
|
|
||||||
// Runtime imports stay relative so this module can run under plain `node --test`.
|
// Runtime imports stay relative so this module can run under plain `node --test`.
|
||||||
import { normalizeDisplay, normalizeKey, splitCollaborators } from '../shared/library/albumGrouping.ts';
|
import { normalizeDisplay, normalizeKey, splitCollaborators } from '../shared/library/albumGrouping.ts';
|
||||||
|
import { normalizeArtistNames } from '../shared/library/artistCredits.ts';
|
||||||
import type { Artist, DbTrack } from '../types/library';
|
import type { Artist, DbTrack } from '../types/library';
|
||||||
|
|
||||||
// Shared with the album-identity port so artist and album grouping can never
|
// Shared with the album-identity port so artist and album grouping can never
|
||||||
@@ -23,9 +24,16 @@ const UNKNOWN_ARTIST = 'Unknown Artist';
|
|||||||
const VARIOUS_ARTISTS_KEY = 'various artists';
|
const VARIOUS_ARTISTS_KEY = 'various artists';
|
||||||
|
|
||||||
/** Track fields the grouping logic reads (subset of DbTrack, for testability). */
|
/** Track fields the grouping logic reads (subset of DbTrack, for testability). */
|
||||||
export type ArtistTrackLike = Pick<
|
export interface ArtistCreditTrackLike {
|
||||||
|
artist: string;
|
||||||
|
artist_names?: readonly string[] | null;
|
||||||
|
album_artist: string | null;
|
||||||
|
album_artist_names?: readonly string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ArtistTrackLike = ArtistCreditTrackLike & Pick<
|
||||||
DbTrack,
|
DbTrack,
|
||||||
'artist' | 'album_artist' | 'artwork_hash' | 'year' | 'added_at' | 'modified_at' | 'album_identity_key'
|
'artwork_hash' | 'year' | 'added_at' | 'modified_at' | 'album_identity_key'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
/** Like splitCollaborators but keeps "&" (e.g. "Earth, Wind & Fire" stays whole). */
|
/** Like splitCollaborators but keeps "&" (e.g. "Earth, Wind & Fire" stays whole). */
|
||||||
@@ -54,23 +62,27 @@ function dedupeByKey(parts: string[]): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** File-tags artist: album_artist if present, else the raw track artist. */
|
/** File-tags artist: album_artist if present, else the raw track artist. */
|
||||||
export function resolveStrictBrowseArtist(track: Pick<DbTrack, 'artist' | 'album_artist'>): string {
|
export function resolveStrictBrowseArtist(track: ArtistCreditTrackLike): string {
|
||||||
const albumArtist = normalizeDisplay(track.album_artist ?? '');
|
const albumArtist = normalizeDisplay(track.album_artist ?? '');
|
||||||
if (albumArtist) return albumArtist;
|
if (albumArtist) return albumArtist;
|
||||||
return normalizeDisplay(track.artist) || UNKNOWN_ARTIST;
|
return normalizeDisplay(track.artist) || UNKNOWN_ARTIST;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Astra-grouping primary: album_artist's first collaborator, else artist's first. */
|
/** Astra-grouping primary: album_artist's first collaborator, else artist's first. */
|
||||||
export function resolveCanonicalBrowseArtist(track: Pick<DbTrack, 'artist' | 'album_artist'>): string {
|
export function resolveCanonicalBrowseArtist(track: ArtistCreditTrackLike): string {
|
||||||
const albumArtist = normalizeDisplay(track.album_artist ?? '');
|
const albumArtist = normalizeDisplay(track.album_artist ?? '');
|
||||||
if (albumArtist) {
|
if (albumArtist) {
|
||||||
|
const parsedAlbumArtists = normalizeArtistNames(track.album_artist_names);
|
||||||
|
if (parsedAlbumArtists.length > 0) return parsedAlbumArtists[0];
|
||||||
return splitAlbumArtistCollaborators(albumArtist)[0] ?? albumArtist;
|
return splitAlbumArtistCollaborators(albumArtist)[0] ?? albumArtist;
|
||||||
}
|
}
|
||||||
|
const parsedTrackArtists = normalizeArtistNames(track.artist_names);
|
||||||
|
if (parsedTrackArtists.length > 0) return parsedTrackArtists[0];
|
||||||
return splitCollaborators(track.artist)[0] ?? UNKNOWN_ARTIST;
|
return splitCollaborators(track.artist)[0] ?? UNKNOWN_ARTIST;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Every artist a track is indexed under in astra mode: primary + all collaborators. */
|
/** Every artist a track is indexed under in astra mode: primary + all collaborators. */
|
||||||
export function getCanonicalArtistIndexNames(track: Pick<DbTrack, 'artist' | 'album_artist'>): string[] {
|
export function getCanonicalArtistIndexNames(track: ArtistCreditTrackLike): string[] {
|
||||||
const unique = new Map<string, string>();
|
const unique = new Map<string, string>();
|
||||||
const add = (name: string) => {
|
const add = (name: string) => {
|
||||||
const display = normalizeDisplay(name);
|
const display = normalizeDisplay(name);
|
||||||
@@ -81,10 +93,17 @@ export function getCanonicalArtistIndexNames(track: Pick<DbTrack, 'artist' | 'al
|
|||||||
|
|
||||||
add(resolveCanonicalBrowseArtist(track));
|
add(resolveCanonicalBrowseArtist(track));
|
||||||
|
|
||||||
const trackArtists = splitCollaborators(track.artist);
|
const parsedTrackArtists = normalizeArtistNames(track.artist_names);
|
||||||
|
const trackArtists =
|
||||||
|
parsedTrackArtists.length > 0 ? parsedTrackArtists : splitCollaborators(track.artist);
|
||||||
for (const name of trackArtists) add(name);
|
for (const name of trackArtists) add(name);
|
||||||
if (trackArtists.length === 0) {
|
if (trackArtists.length === 0) {
|
||||||
for (const name of splitAlbumArtistCollaborators(track.album_artist ?? '')) add(name);
|
const parsedAlbumArtists = normalizeArtistNames(track.album_artist_names);
|
||||||
|
const albumArtists =
|
||||||
|
parsedAlbumArtists.length > 0
|
||||||
|
? parsedAlbumArtists
|
||||||
|
: splitAlbumArtistCollaborators(track.album_artist ?? '');
|
||||||
|
for (const name of albumArtists) add(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.from(unique.values());
|
return Array.from(unique.values());
|
||||||
@@ -98,18 +117,18 @@ export function getCanonicalArtistIndexNames(track: Pick<DbTrack, 'artist' | 'al
|
|||||||
* bucket is the only artist page that lists those tracks in that mode.
|
* bucket is the only artist page that lists those tracks in that mode.
|
||||||
*/
|
*/
|
||||||
export function resolveNavigationArtist(
|
export function resolveNavigationArtist(
|
||||||
track: Pick<DbTrack, 'artist' | 'album_artist'>,
|
track: ArtistCreditTrackLike,
|
||||||
mode: ArtistGroupingMode
|
mode: ArtistGroupingMode
|
||||||
): string {
|
): string {
|
||||||
if (mode === 'fileTags') return resolveStrictBrowseArtist(track);
|
if (mode === 'fileTags') return resolveStrictBrowseArtist(track);
|
||||||
const canonical = resolveCanonicalBrowseArtist(track);
|
const canonical = resolveCanonicalBrowseArtist(track);
|
||||||
if (normalizeKey(canonical) !== VARIOUS_ARTISTS_KEY) return canonical;
|
if (normalizeKey(canonical) !== VARIOUS_ARTISTS_KEY) return canonical;
|
||||||
return splitCollaborators(track.artist)[0] ?? canonical;
|
return normalizeArtistNames(track.artist_names)[0] ?? splitCollaborators(track.artist)[0] ?? canonical;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether a track belongs to the given artist key under the active browse mode. */
|
/** Whether a track belongs to the given artist key under the active browse mode. */
|
||||||
export function trackMatchesBrowseArtist(
|
export function trackMatchesBrowseArtist(
|
||||||
track: Pick<DbTrack, 'artist' | 'album_artist'>,
|
track: ArtistCreditTrackLike,
|
||||||
targetArtistKey: string,
|
targetArtistKey: string,
|
||||||
mode: ArtistGroupingMode
|
mode: ArtistGroupingMode
|
||||||
): boolean {
|
): boolean {
|
||||||
@@ -125,9 +144,15 @@ export function trackMatchesBrowseArtist(
|
|||||||
const trackArtistKey = normalizeKey(track.artist);
|
const trackArtistKey = normalizeKey(track.artist);
|
||||||
if (trackArtistKey && trackArtistKey === targetArtistKey) return true;
|
if (trackArtistKey && trackArtistKey === targetArtistKey) return true;
|
||||||
|
|
||||||
|
if (normalizeArtistNames(track.album_artist_names).some((n) => normalizeKey(n) === targetArtistKey)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (splitAlbumArtistCollaborators(track.album_artist ?? '').some((n) => normalizeKey(n) === targetArtistKey)) {
|
if (splitAlbumArtistCollaborators(track.album_artist ?? '').some((n) => normalizeKey(n) === targetArtistKey)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (normalizeArtistNames(track.artist_names).some((n) => normalizeKey(n) === targetArtistKey)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return splitCollaborators(track.artist).some((n) => normalizeKey(n) === targetArtistKey);
|
return splitCollaborators(track.artist).some((n) => normalizeKey(n) === targetArtistKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ type NativeFolder = LibraryFolder & {
|
|||||||
track_count: number;
|
track_count: number;
|
||||||
scan_status?: string;
|
scan_status?: string;
|
||||||
scan_error?: string | null;
|
scan_error?: string | null;
|
||||||
|
needs_metadata_reindex?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function displayNameFromTreeUri(treeUri: string): string {
|
function displayNameFromTreeUri(treeUri: string): string {
|
||||||
|
|||||||
@@ -25,8 +25,11 @@ export function dbTrackToTrack(track: DbTrack): Track {
|
|||||||
origin: 'library',
|
origin: 'library',
|
||||||
title: track.title,
|
title: track.title,
|
||||||
artist: track.artist,
|
artist: track.artist,
|
||||||
|
artistNames: track.artist_names?.length > 0 ? track.artist_names : undefined,
|
||||||
album: track.album,
|
album: track.album,
|
||||||
albumArtist: track.album_artist ?? undefined,
|
albumArtist: track.album_artist ?? undefined,
|
||||||
|
albumArtistNames:
|
||||||
|
track.album_artist_names?.length > 0 ? track.album_artist_names : undefined,
|
||||||
albumIdentityKey: track.album_identity_key,
|
albumIdentityKey: track.album_identity_key,
|
||||||
duration: track.duration,
|
duration: track.duration,
|
||||||
trackNumber: track.track_number ?? undefined,
|
trackNumber: track.track_number ?? undefined,
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
// Port of desktop astra/src/shared/library/albumGrouping.ts — keep semantically
|
// Port of desktop astra/src/shared/library/albumGrouping.ts — keep semantically
|
||||||
// identical so album identities match the desktop app. Mobile has no
|
// identical so album identities match the desktop app. Structured artist
|
||||||
// artist_names/*_names_json columns; those inputs stay undefined and the module
|
// arrays are preferred; legacy and remote rows without them fall through to
|
||||||
// falls through to the string-splitting paths, exactly like desktop does for
|
// the same string-splitting paths desktop uses for single-value tags.
|
||||||
// files without multi-value tags.
|
|
||||||
|
|
||||||
// Explicit .ts extension so the module resolves under plain `node --test`
|
// Explicit .ts extension so the module resolves under plain `node --test`
|
||||||
// (Metro and tsc accept it via allowImportingTsExtensions).
|
// (Metro and tsc accept it via allowImportingTsExtensions).
|
||||||
|
|||||||
@@ -53,7 +53,10 @@ function persistSetting(key: string, value: string) {
|
|||||||
void AstraLibraryData.setSettings({ [key]: value });
|
void AstraLibraryData.setSettings({ [key]: value });
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FolderWithCount = LibraryFolder & { track_count: number };
|
export type FolderWithCount = LibraryFolder & {
|
||||||
|
track_count: number;
|
||||||
|
needs_metadata_reindex?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
interface ScanProgressState {
|
interface ScanProgressState {
|
||||||
phase: 'idle' | 'discovering' | 'extracting' | 'analyzing';
|
phase: 'idle' | 'discovering' | 'extracting' | 'analyzing';
|
||||||
@@ -487,7 +490,10 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
|
|||||||
|
|
||||||
await get().refresh();
|
await get().refresh();
|
||||||
set({ initialized: true });
|
set({ initialized: true });
|
||||||
if (status.status === 'rebuilding' && !get().isScanning) {
|
const needsMetadataReindex = get().folders.some(
|
||||||
|
(folder) => folder.available && folder.needs_metadata_reindex === true
|
||||||
|
);
|
||||||
|
if ((status.status === 'rebuilding' || needsMetadataReindex) && !get().isScanning) {
|
||||||
void get().rebuildLocalIndex();
|
void get().rebuildLocalIndex();
|
||||||
}
|
}
|
||||||
})().catch((error) => {
|
})().catch((error) => {
|
||||||
|
|||||||
@@ -9,8 +9,12 @@ export interface DbTrack {
|
|||||||
folder_id: number | null; // NULL for remote tracks (no SAF folder)
|
folder_id: number | null; // NULL for remote tracks (no SAF folder)
|
||||||
title: string;
|
title: string;
|
||||||
artist: string;
|
artist: string;
|
||||||
|
/** Ordered repeated ARTIST tag values; empty for legacy/single-value rows. */
|
||||||
|
artist_names: string[];
|
||||||
album: string;
|
album: string;
|
||||||
album_artist: string | null;
|
album_artist: string | null;
|
||||||
|
/** Ordered repeated ALBUMARTIST values; empty for legacy/single-value rows. */
|
||||||
|
album_artist_names: string[];
|
||||||
album_identity_key: string;
|
album_identity_key: string;
|
||||||
/** Settled group artist ("Various Artists" for shared-artwork compilations);
|
/** Settled group artist ("Various Artists" for shared-artwork compilations);
|
||||||
* written by the album-identity recompute pass, NULL until it first runs. */
|
* written by the album-identity recompute pass, NULL until it first runs. */
|
||||||
|
|||||||
Reference in New Issue
Block a user