mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-17 03:03:07 +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. */
|
||||
private fun seedAlphabet(): List<TrackEntity> =
|
||||
(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.Record
|
||||
import expo.modules.astralibraryscanner.data.AstraLibraryRepository
|
||||
import expo.modules.astralibraryscanner.data.ArtistCreditMetadataReader
|
||||
import expo.modules.astralibraryscanner.data.LocalAudioFile
|
||||
import expo.modules.astralibraryscanner.data.LocalAudioMetadata
|
||||
import expo.modules.astralibraryscanner.data.ScanCancelledException
|
||||
import expo.modules.astralibraryscanner.data.formatArtistNames
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
@@ -651,6 +653,22 @@ class AstraLibraryScannerModule : Module() {
|
||||
} 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
|
||||
// API 31+ (sample rate). Failure here is non-fatal — keep the tag data.
|
||||
val extractor = MediaExtractor()
|
||||
@@ -693,8 +711,11 @@ class AstraLibraryScannerModule : Module() {
|
||||
ok = this["ok"] as? Boolean ?: false,
|
||||
title = this["title"] as? String,
|
||||
artist = this["artist"] as? String,
|
||||
artistNames = (this["artistNames"] as? List<*>)?.filterIsInstance<String>().orEmpty(),
|
||||
album = this["album"] as? String,
|
||||
albumArtist = this["albumArtist"] as? String,
|
||||
albumArtistNames =
|
||||
(this["albumArtistNames"] as? List<*>)?.filterIsInstance<String>().orEmpty(),
|
||||
genre = this["genre"] as? String,
|
||||
mimeType = this["mimeType"] as? String,
|
||||
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() }
|
||||
val catalogDao = requireCatalog().catalogDao()
|
||||
return requireUser().userDao().getFolders().map { folder ->
|
||||
val source = catalogDao.getSource(localSourceKey(folder.id))
|
||||
mapOf(
|
||||
"id" to folder.id.toDouble(),
|
||||
"tree_uri" to folder.treeUri,
|
||||
@@ -267,6 +268,10 @@ class AstraLibraryRepository private constructor(
|
||||
"scan_status" to folder.lastScanStatus,
|
||||
"scan_error" to folder.lastScanError,
|
||||
"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 sourceKey = localSourceKey(folderId)
|
||||
val previousSource = dao.getSource(sourceKey)
|
||||
val effectiveFull = full ||
|
||||
(previousSource != null &&
|
||||
previousSource.artistCreditVersion < CURRENT_ARTIST_CREDIT_VERSION)
|
||||
val generationId = UUID.randomUUID().toString()
|
||||
val startedAt = System.currentTimeMillis()
|
||||
|
||||
@@ -415,7 +423,7 @@ class AstraLibraryRepository private constructor(
|
||||
async(Dispatchers.IO) {
|
||||
throwIfScanCancelled(isCancelled)
|
||||
val old = existingByPath[file.uri]
|
||||
val unchanged = !full &&
|
||||
val unchanged = !effectiveFull &&
|
||||
old != null &&
|
||||
old.mtime == file.lastModified &&
|
||||
old.size == file.size
|
||||
@@ -491,6 +499,7 @@ class AstraLibraryRepository private constructor(
|
||||
artistTrackIndex = readModels.artistTrackIndex,
|
||||
directories = readModels.directories,
|
||||
ftsRows = readModels.ftsRows,
|
||||
artistCreditVersion = CURRENT_ARTIST_CREDIT_VERSION,
|
||||
)
|
||||
userDao.updateFolderScanState(folderId, System.currentTimeMillis(), "ready", null)
|
||||
scheduleSnapshot()
|
||||
@@ -2502,10 +2511,27 @@ class AstraLibraryRepository private constructor(
|
||||
val extension = file.name.substringAfterLast('.', "")
|
||||
val title = clean(metadata.title)
|
||||
?: 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 albumArtist = clean(metadata.albumArtist)
|
||||
val provisional = CatalogReadModelBuilder.provisionalIdentity(album, artist, albumArtist)
|
||||
val albumArtist = if (albumArtistNames.size > 1) {
|
||||
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()
|
||||
return TrackEntity(
|
||||
generationId = generationId,
|
||||
@@ -2514,8 +2540,10 @@ class AstraLibraryRepository private constructor(
|
||||
folderId = folderId,
|
||||
title = title,
|
||||
artist = artist,
|
||||
artistNamesJson = artistNamesJson,
|
||||
album = album,
|
||||
albumArtist = albumArtist,
|
||||
albumArtistNamesJson = albumArtistNamesJson,
|
||||
albumIdentityKey = provisional.first,
|
||||
albumDisplayArtist = provisional.second,
|
||||
duration = (metadata.durationMs ?: 0L) / 1_000.0,
|
||||
@@ -2739,6 +2767,7 @@ class AstraLibraryRepository private constructor(
|
||||
private fun buildCatalogDatabase(): AstraCatalogDatabase =
|
||||
Room.databaseBuilder(applicationContext, AstraCatalogDatabase::class.java, CATALOG_DB_NAME)
|
||||
.setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING)
|
||||
.addMigrations(CATALOG_MIGRATION_1_2)
|
||||
.fallbackToDestructiveMigration(true)
|
||||
.build()
|
||||
|
||||
|
||||
+22
-2
@@ -9,7 +9,9 @@ import androidx.room.RawQuery
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.Transaction
|
||||
import androidx.room.Upsert
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteQuery
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
data class TrackSyncRow(
|
||||
val path: String,
|
||||
@@ -87,6 +89,7 @@ interface CatalogDao {
|
||||
"""
|
||||
UPDATE catalog_sources
|
||||
SET active_generation_id = :generationId,
|
||||
artist_credit_version = COALESCE(:artistCreditVersion, artist_credit_version),
|
||||
updated_at = :updatedAt
|
||||
WHERE source_key = :sourceKey
|
||||
""",
|
||||
@@ -95,6 +98,7 @@ interface CatalogDao {
|
||||
sourceKey: String,
|
||||
generationId: String,
|
||||
updatedAt: Long,
|
||||
artistCreditVersion: Int? = null,
|
||||
)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.ABORT)
|
||||
@@ -1206,11 +1210,12 @@ interface CatalogDao {
|
||||
artistTrackIndex: List<ArtistTrackIndexEntity>,
|
||||
directories: List<DirectorySummaryEntity>,
|
||||
ftsRows: List<TrackFtsEntity>,
|
||||
artistCreditVersion: Int? = null,
|
||||
): Long {
|
||||
for (update in albumIdentityUpdates) {
|
||||
updateAlbumIdentity(update.trackId, update.identityKey, update.displayArtist)
|
||||
}
|
||||
setActiveGeneration(sourceKey, generationId, now)
|
||||
setActiveGeneration(sourceKey, generationId, now, artistCreditVersion)
|
||||
setGenerationState(generationId, "active", now, null)
|
||||
incrementRevision(now)
|
||||
val revision = getRevision()
|
||||
@@ -1283,9 +1288,24 @@ interface CatalogDao {
|
||||
TrackFtsEntity::class,
|
||||
],
|
||||
views = [ActiveTrackView::class],
|
||||
version = 1,
|
||||
version = 2,
|
||||
exportSchema = true,
|
||||
)
|
||||
abstract class AstraCatalogDatabase : RoomDatabase() {
|
||||
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 = "active_generation_id") val activeGenerationId: String? = null,
|
||||
@ColumnInfo(name = "updated_at") val updatedAt: Long,
|
||||
@ColumnInfo(name = "artist_credit_version", defaultValue = "2")
|
||||
val artistCreditVersion: Int = CURRENT_ARTIST_CREDIT_VERSION,
|
||||
)
|
||||
|
||||
@Entity(
|
||||
@@ -60,8 +62,10 @@ data class TrackEntity(
|
||||
@ColumnInfo(name = "folder_id") val folderId: Long? = null,
|
||||
val title: String,
|
||||
val artist: String,
|
||||
@ColumnInfo(name = "artist_names_json") val artistNamesJson: String? = null,
|
||||
val album: String,
|
||||
@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_display_artist") val albumDisplayArtist: String? = null,
|
||||
val duration: Double = 0.0,
|
||||
@@ -123,8 +127,10 @@ data class ActiveTrackView(
|
||||
@ColumnInfo(name = "folder_id") val folderId: Long?,
|
||||
val title: String,
|
||||
val artist: String,
|
||||
@ColumnInfo(name = "artist_names_json") val artistNamesJson: String?,
|
||||
val album: 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_display_artist") val albumDisplayArtist: String?,
|
||||
val duration: Double,
|
||||
|
||||
+26
-7
@@ -33,8 +33,10 @@ data class LocalAudioMetadata(
|
||||
val ok: Boolean,
|
||||
val title: String? = null,
|
||||
val artist: String? = null,
|
||||
val artistNames: List<String> = emptyList(),
|
||||
val album: String? = null,
|
||||
val albumArtist: String? = null,
|
||||
val albumArtistNames: List<String> = emptyList(),
|
||||
val genre: String? = null,
|
||||
val mimeType: String? = null,
|
||||
val durationMs: Long? = null,
|
||||
@@ -128,13 +130,14 @@ object CatalogReadModelBuilder {
|
||||
album: String,
|
||||
artist: String,
|
||||
albumArtist: String?,
|
||||
artistNamesJson: String? = null,
|
||||
): Pair<String, String> {
|
||||
val albumKey = normalizeKey(normalizeAlbum(album))
|
||||
val explicit = normalizeDisplay(albumArtist.orEmpty())
|
||||
if (explicit.isNotEmpty()) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -145,7 +148,7 @@ object CatalogReadModelBuilder {
|
||||
for (track in tracks) {
|
||||
val albumKey = normalizeKey(normalizeAlbum(track.album))
|
||||
val explicit = normalizeDisplay(track.albumArtist.orEmpty())
|
||||
val primary = primaryArtist(track.artist)
|
||||
val primary = primaryArtist(track.artist, track.artistNamesJson)
|
||||
val prepared = PreparedAlbumTrack(
|
||||
track = track,
|
||||
albumKey = albumKey,
|
||||
@@ -408,9 +411,11 @@ object CatalogReadModelBuilder {
|
||||
private fun canonicalPrimary(track: TrackEntity): String {
|
||||
val albumArtist = normalizeDisplay(track.albumArtist.orEmpty())
|
||||
if (albumArtist.isNotEmpty()) {
|
||||
val parsedAlbumArtists = deserializeArtistNames(track.albumArtistNamesJson)
|
||||
if (parsedAlbumArtists.isNotEmpty()) return parsedAlbumArtists[0]
|
||||
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> {
|
||||
@@ -421,9 +426,21 @@ object CatalogReadModelBuilder {
|
||||
if (key.isNotEmpty()) result.putIfAbsent(key, display)
|
||||
}
|
||||
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)
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -480,8 +497,10 @@ object CatalogReadModelBuilder {
|
||||
private fun normalizeAlbum(value: String): String =
|
||||
normalizeDisplay(value).ifEmpty { UNKNOWN_ALBUM }
|
||||
|
||||
private fun primaryArtist(value: String): String =
|
||||
splitTrackArtists(value).firstOrNull() ?: UNKNOWN_ARTIST
|
||||
private fun primaryArtist(value: String, artistNamesJson: String? = null): String =
|
||||
deserializeArtistNames(artistNamesJson).firstOrNull()
|
||||
?: splitTrackArtists(value).firstOrNull()
|
||||
?: UNKNOWN_ARTIST
|
||||
|
||||
private fun identity(albumKey: String, discriminator: String): String =
|
||||
"album:$albumKey::$discriminator"
|
||||
|
||||
+2
@@ -133,8 +133,10 @@ fun ActiveTrackView.toBridgeMap(): Map<String, Any?> = mapOf(
|
||||
"folder_id" to folderId?.toDouble(),
|
||||
"title" to title,
|
||||
"artist" to artist,
|
||||
"artist_names" to deserializeArtistNames(artistNamesJson),
|
||||
"album" to album,
|
||||
"album_artist" to albumArtist,
|
||||
"album_artist_names" to deserializeArtistNames(albumArtistNamesJson),
|
||||
"album_identity_key" to albumIdentityKey,
|
||||
"album_display_artist" to albumDisplayArtist,
|
||||
"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())
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user