From ce4e17323115b304cd767dc53a12c95d01859d08 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:30:01 -0400 Subject: [PATCH] added desktop parity multi artist metadata --- .../2.json | 1166 +++++++++++++++++ .../data/ArtistCreditMetadataReaderTest.kt | 62 + .../data/CatalogMigrationTest.kt | 77 ++ .../data/RoomLibraryRepositoryTest.kt | 70 + .../AstraLibraryScannerModule.kt | 21 + .../astralibraryscanner/data/ArtistCredits.kt | 115 ++ .../data/AstraLibraryRepository.kt | 37 +- .../data/CatalogDatabase.kt | 24 +- .../data/CatalogEntities.kt | 6 + .../data/CatalogReadModelBuilder.kt | 33 +- .../astralibraryscanner/data/LibraryModels.kt | 2 + .../data/ArtistCreditsTest.kt | 47 + modules/astra-library-scanner/index.ts | 4 + package.json | 2 +- src/audio/artistCreditTransport.test.mts | 20 + src/audio/artistCreditTransport.ts | 20 + src/audio/sampleTracks.ts | 14 + src/components/player/NowPlayingOverlay.tsx | 21 +- .../player/nowPlayingPreferences.test.mts | 7 + src/library/albumIdentity.test.mts | 13 + src/library/albumIdentity.ts | 25 +- src/library/artistGrouping.test.mts | 33 + src/library/artistGrouping.ts | 51 +- src/library/scanner.ts | 1 + src/library/trackAdapter.ts | 3 + src/shared/library/albumGrouping.ts | 7 +- src/stores/libraryStore.ts | 10 +- src/types/library.ts | 4 + 28 files changed, 1849 insertions(+), 46 deletions(-) create mode 100644 modules/astra-library-scanner/android/schemas/expo.modules.astralibraryscanner.data.AstraCatalogDatabase/2.json create mode 100644 modules/astra-library-scanner/android/src/androidTest/java/expo/modules/astralibraryscanner/data/ArtistCreditMetadataReaderTest.kt create mode 100644 modules/astra-library-scanner/android/src/androidTest/java/expo/modules/astralibraryscanner/data/CatalogMigrationTest.kt create mode 100644 modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/ArtistCredits.kt create mode 100644 modules/astra-library-scanner/android/src/test/java/expo/modules/astralibraryscanner/data/ArtistCreditsTest.kt create mode 100644 src/audio/artistCreditTransport.test.mts create mode 100644 src/audio/artistCreditTransport.ts diff --git a/modules/astra-library-scanner/android/schemas/expo.modules.astralibraryscanner.data.AstraCatalogDatabase/2.json b/modules/astra-library-scanner/android/schemas/expo.modules.astralibraryscanner.data.AstraCatalogDatabase/2.json new file mode 100644 index 0000000..3f3873c --- /dev/null +++ b/modules/astra-library-scanner/android/schemas/expo.modules.astralibraryscanner.data.AstraCatalogDatabase/2.json @@ -0,0 +1,1166 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "518187e943df90394b038637134bc028", + "entities": [ + { + "tableName": "catalog_meta", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `revision` INTEGER NOT NULL, `collation_version` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "revision", + "columnName": "revision", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "collationVersion", + "columnName": "collation_version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "catalog_sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`source_key` TEXT NOT NULL, `source_type` TEXT NOT NULL, `source_id` INTEGER NOT NULL, `active_generation_id` TEXT, `artist_credit_version` INTEGER NOT NULL DEFAULT 2, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`source_key`))", + "fields": [ + { + "fieldPath": "sourceKey", + "columnName": "source_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceType", + "columnName": "source_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceId", + "columnName": "source_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "activeGenerationId", + "columnName": "active_generation_id", + "affinity": "TEXT" + }, + { + "fieldPath": "artistCreditVersion", + "columnName": "artist_credit_version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "2" + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "source_key" + ] + } + }, + { + "tableName": "scan_generations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `source_key` TEXT NOT NULL, `state` TEXT NOT NULL, `started_at` INTEGER NOT NULL, `finished_at` INTEGER, `error_message` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceKey", + "columnName": "source_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startedAt", + "columnName": "started_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "finishedAt", + "columnName": "finished_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "errorMessage", + "columnName": "error_message", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_scan_generations_source_key_state", + "unique": false, + "columnNames": [ + "source_key", + "state" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_scan_generations_source_key_state` ON `${TABLE_NAME}` (`source_key`, `state`)" + } + ] + }, + { + "tableName": "tracks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `generation_id` TEXT NOT NULL, `source_key` TEXT NOT NULL, `path` TEXT NOT NULL, `folder_id` INTEGER, `title` TEXT NOT NULL, `artist` TEXT NOT NULL, `artist_names_json` TEXT, `album` TEXT NOT NULL, `album_artist` TEXT, `album_artist_names_json` TEXT, `album_identity_key` TEXT NOT NULL, `album_display_artist` TEXT, `duration` REAL NOT NULL, `track_number` INTEGER, `disc_number` INTEGER, `year` INTEGER, `genre` TEXT, `artwork_hash` TEXT, `format` TEXT NOT NULL, `sample_rate` INTEGER, `bit_depth` INTEGER, `bitrate` INTEGER, `channels` INTEGER, `codec` TEXT, `source_type` TEXT NOT NULL, `source_id` INTEGER, `source_track_id` TEXT, `source_path` TEXT, `artwork_source_id` TEXT, `file_name` TEXT NOT NULL, `parent_uri` TEXT, `size` INTEGER, `mtime` INTEGER NOT NULL, `added_at` INTEGER NOT NULL, `modified_at` INTEGER NOT NULL, `loudness_lufs` REAL, `sample_peak` REAL, `replay_gain_track_db` REAL, `replay_gain_album_db` REAL, `replay_gain_track_peak` REAL, `replay_gain_album_peak` REAL, `rg_scanned` INTEGER NOT NULL, `bpm` REAL, `musical_key` TEXT, `title_sort_key` TEXT NOT NULL, `artist_sort_key` TEXT NOT NULL, `album_sort_key` TEXT NOT NULL, `file_name_sort_key` TEXT NOT NULL, `disc_sort` INTEGER NOT NULL, `track_sort` INTEGER NOT NULL, `section_label` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "generationId", + "columnName": "generation_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceKey", + "columnName": "source_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "folderId", + "columnName": "folder_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistNamesJson", + "columnName": "artist_names_json", + "affinity": "TEXT" + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumArtist", + "columnName": "album_artist", + "affinity": "TEXT" + }, + { + "fieldPath": "albumArtistNamesJson", + "columnName": "album_artist_names_json", + "affinity": "TEXT" + }, + { + "fieldPath": "albumIdentityKey", + "columnName": "album_identity_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumDisplayArtist", + "columnName": "album_display_artist", + "affinity": "TEXT" + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "trackNumber", + "columnName": "track_number", + "affinity": "INTEGER" + }, + { + "fieldPath": "discNumber", + "columnName": "disc_number", + "affinity": "INTEGER" + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER" + }, + { + "fieldPath": "genre", + "columnName": "genre", + "affinity": "TEXT" + }, + { + "fieldPath": "artworkHash", + "columnName": "artwork_hash", + "affinity": "TEXT" + }, + { + "fieldPath": "format", + "columnName": "format", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sampleRate", + "columnName": "sample_rate", + "affinity": "INTEGER" + }, + { + "fieldPath": "bitDepth", + "columnName": "bit_depth", + "affinity": "INTEGER" + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER" + }, + { + "fieldPath": "channels", + "columnName": "channels", + "affinity": "INTEGER" + }, + { + "fieldPath": "codec", + "columnName": "codec", + "affinity": "TEXT" + }, + { + "fieldPath": "sourceType", + "columnName": "source_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceId", + "columnName": "source_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "sourceTrackId", + "columnName": "source_track_id", + "affinity": "TEXT" + }, + { + "fieldPath": "sourcePath", + "columnName": "source_path", + "affinity": "TEXT" + }, + { + "fieldPath": "artworkSourceId", + "columnName": "artwork_source_id", + "affinity": "TEXT" + }, + { + "fieldPath": "fileName", + "columnName": "file_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentUri", + "columnName": "parent_uri", + "affinity": "TEXT" + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER" + }, + { + "fieldPath": "mtime", + "columnName": "mtime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addedAt", + "columnName": "added_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedAt", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loudnessLufs", + "columnName": "loudness_lufs", + "affinity": "REAL" + }, + { + "fieldPath": "samplePeak", + "columnName": "sample_peak", + "affinity": "REAL" + }, + { + "fieldPath": "replayGainTrackDb", + "columnName": "replay_gain_track_db", + "affinity": "REAL" + }, + { + "fieldPath": "replayGainAlbumDb", + "columnName": "replay_gain_album_db", + "affinity": "REAL" + }, + { + "fieldPath": "replayGainTrackPeak", + "columnName": "replay_gain_track_peak", + "affinity": "REAL" + }, + { + "fieldPath": "replayGainAlbumPeak", + "columnName": "replay_gain_album_peak", + "affinity": "REAL" + }, + { + "fieldPath": "replayGainScanned", + "columnName": "rg_scanned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bpm", + "columnName": "bpm", + "affinity": "REAL" + }, + { + "fieldPath": "musicalKey", + "columnName": "musical_key", + "affinity": "TEXT" + }, + { + "fieldPath": "titleSortKey", + "columnName": "title_sort_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistSortKey", + "columnName": "artist_sort_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumSortKey", + "columnName": "album_sort_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileNameSortKey", + "columnName": "file_name_sort_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "discSort", + "columnName": "disc_sort", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "trackSort", + "columnName": "track_sort", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sectionLabel", + "columnName": "section_label", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_tracks_generation_id_path", + "unique": true, + "columnNames": [ + "generation_id", + "path" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_tracks_generation_id_path` ON `${TABLE_NAME}` (`generation_id`, `path`)" + }, + { + "name": "index_tracks_source_key_generation_id", + "unique": false, + "columnNames": [ + "source_key", + "generation_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tracks_source_key_generation_id` ON `${TABLE_NAME}` (`source_key`, `generation_id`)" + }, + { + "name": "index_tracks_album_identity_key", + "unique": false, + "columnNames": [ + "album_identity_key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tracks_album_identity_key` ON `${TABLE_NAME}` (`album_identity_key`)" + }, + { + "name": "index_tracks_artist_sort_key_album_sort_key_disc_sort_track_sort_title_sort_key_path", + "unique": false, + "columnNames": [ + "artist_sort_key", + "album_sort_key", + "disc_sort", + "track_sort", + "title_sort_key", + "path" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tracks_artist_sort_key_album_sort_key_disc_sort_track_sort_title_sort_key_path` ON `${TABLE_NAME}` (`artist_sort_key`, `album_sort_key`, `disc_sort`, `track_sort`, `title_sort_key`, `path`)" + }, + { + "name": "index_tracks_title_sort_key_path", + "unique": false, + "columnNames": [ + "title_sort_key", + "path" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tracks_title_sort_key_path` ON `${TABLE_NAME}` (`title_sort_key`, `path`)" + }, + { + "name": "index_tracks_added_at_path", + "unique": false, + "columnNames": [ + "added_at", + "path" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tracks_added_at_path` ON `${TABLE_NAME}` (`added_at`, `path`)" + }, + { + "name": "index_tracks_duration_path", + "unique": false, + "columnNames": [ + "duration", + "path" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tracks_duration_path` ON `${TABLE_NAME}` (`duration`, `path`)" + } + ] + }, + { + "tableName": "album_summaries", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`revision` INTEGER NOT NULL, `identity_key` TEXT NOT NULL, `album` TEXT NOT NULL, `artist` TEXT NOT NULL, `year` INTEGER, `artwork_hash` TEXT, `source_type` TEXT, `source_id` INTEGER, `artwork_source_id` TEXT, `track_count` INTEGER NOT NULL, `total_duration` REAL NOT NULL, `latest_added_at` INTEGER NOT NULL, `name_sort_key` TEXT NOT NULL, `artist_sort_key` TEXT NOT NULL, `section_label` TEXT NOT NULL, `is_single` INTEGER NOT NULL, PRIMARY KEY(`revision`, `identity_key`))", + "fields": [ + { + "fieldPath": "revision", + "columnName": "revision", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityKey", + "columnName": "identity_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER" + }, + { + "fieldPath": "artworkHash", + "columnName": "artwork_hash", + "affinity": "TEXT" + }, + { + "fieldPath": "sourceType", + "columnName": "source_type", + "affinity": "TEXT" + }, + { + "fieldPath": "sourceId", + "columnName": "source_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "artworkSourceId", + "columnName": "artwork_source_id", + "affinity": "TEXT" + }, + { + "fieldPath": "trackCount", + "columnName": "track_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalDuration", + "columnName": "total_duration", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "latestAddedAt", + "columnName": "latest_added_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nameSortKey", + "columnName": "name_sort_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistSortKey", + "columnName": "artist_sort_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sectionLabel", + "columnName": "section_label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isSingle", + "columnName": "is_single", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "revision", + "identity_key" + ] + }, + "indices": [ + { + "name": "index_album_summaries_revision_name_sort_key_identity_key", + "unique": false, + "columnNames": [ + "revision", + "name_sort_key", + "identity_key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_summaries_revision_name_sort_key_identity_key` ON `${TABLE_NAME}` (`revision`, `name_sort_key`, `identity_key`)" + }, + { + "name": "index_album_summaries_revision_artist_sort_key_name_sort_key_identity_key", + "unique": false, + "columnNames": [ + "revision", + "artist_sort_key", + "name_sort_key", + "identity_key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_summaries_revision_artist_sort_key_name_sort_key_identity_key` ON `${TABLE_NAME}` (`revision`, `artist_sort_key`, `name_sort_key`, `identity_key`)" + }, + { + "name": "index_album_summaries_revision_latest_added_at_identity_key", + "unique": false, + "columnNames": [ + "revision", + "latest_added_at", + "identity_key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_summaries_revision_latest_added_at_identity_key` ON `${TABLE_NAME}` (`revision`, `latest_added_at`, `identity_key`)" + }, + { + "name": "index_album_summaries_revision_year_name_sort_key_identity_key", + "unique": false, + "columnNames": [ + "revision", + "year", + "name_sort_key", + "identity_key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_summaries_revision_year_name_sort_key_identity_key` ON `${TABLE_NAME}` (`revision`, `year`, `name_sort_key`, `identity_key`)" + } + ] + }, + { + "tableName": "artist_summaries", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`revision` INTEGER NOT NULL, `artist_key` TEXT NOT NULL, `artist` TEXT NOT NULL, `grouping_mode` TEXT NOT NULL, `track_count` INTEGER NOT NULL, `primary_track_count` INTEGER NOT NULL, `album_count` INTEGER NOT NULL, `artwork_hash` TEXT, `source_type` TEXT, `source_id` INTEGER, `artwork_source_id` TEXT, `name_sort_key` TEXT NOT NULL, `section_label` TEXT NOT NULL, `is_collaboration` INTEGER NOT NULL, `artwork_hashes_json` TEXT NOT NULL, PRIMARY KEY(`revision`, `artist_key`, `grouping_mode`))", + "fields": [ + { + "fieldPath": "revision", + "columnName": "revision", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "artistKey", + "columnName": "artist_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "groupingMode", + "columnName": "grouping_mode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trackCount", + "columnName": "track_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "primaryTrackCount", + "columnName": "primary_track_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "albumCount", + "columnName": "album_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "artworkHash", + "columnName": "artwork_hash", + "affinity": "TEXT" + }, + { + "fieldPath": "sourceType", + "columnName": "source_type", + "affinity": "TEXT" + }, + { + "fieldPath": "sourceId", + "columnName": "source_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "artworkSourceId", + "columnName": "artwork_source_id", + "affinity": "TEXT" + }, + { + "fieldPath": "nameSortKey", + "columnName": "name_sort_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sectionLabel", + "columnName": "section_label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isCollaboration", + "columnName": "is_collaboration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "artworkHashesJson", + "columnName": "artwork_hashes_json", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "revision", + "artist_key", + "grouping_mode" + ] + }, + "indices": [ + { + "name": "index_artist_summaries_revision_grouping_mode_name_sort_key_artist_key", + "unique": false, + "columnNames": [ + "revision", + "grouping_mode", + "name_sort_key", + "artist_key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artist_summaries_revision_grouping_mode_name_sort_key_artist_key` ON `${TABLE_NAME}` (`revision`, `grouping_mode`, `name_sort_key`, `artist_key`)" + }, + { + "name": "index_artist_summaries_revision_grouping_mode_track_count_name_sort_key_artist_key", + "unique": false, + "columnNames": [ + "revision", + "grouping_mode", + "track_count", + "name_sort_key", + "artist_key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artist_summaries_revision_grouping_mode_track_count_name_sort_key_artist_key` ON `${TABLE_NAME}` (`revision`, `grouping_mode`, `track_count`, `name_sort_key`, `artist_key`)" + } + ] + }, + { + "tableName": "artist_track_index", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`revision` INTEGER NOT NULL, `grouping_mode` TEXT NOT NULL, `artist_key` TEXT NOT NULL, `track_id` INTEGER NOT NULL, `relationship` TEXT NOT NULL, PRIMARY KEY(`revision`, `grouping_mode`, `artist_key`, `track_id`))", + "fields": [ + { + "fieldPath": "revision", + "columnName": "revision", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupingMode", + "columnName": "grouping_mode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistKey", + "columnName": "artist_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trackId", + "columnName": "track_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "relationship", + "columnName": "relationship", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "revision", + "grouping_mode", + "artist_key", + "track_id" + ] + }, + "indices": [ + { + "name": "index_artist_track_index_revision_grouping_mode_artist_key_relationship_track_id", + "unique": false, + "columnNames": [ + "revision", + "grouping_mode", + "artist_key", + "relationship", + "track_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artist_track_index_revision_grouping_mode_artist_key_relationship_track_id` ON `${TABLE_NAME}` (`revision`, `grouping_mode`, `artist_key`, `relationship`, `track_id`)" + }, + { + "name": "index_artist_track_index_track_id", + "unique": false, + "columnNames": [ + "track_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artist_track_index_track_id` ON `${TABLE_NAME}` (`track_id`)" + } + ] + }, + { + "tableName": "directory_summaries", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`revision` INTEGER NOT NULL, `node_id` TEXT NOT NULL, `folder_id` INTEGER NOT NULL, `parent_node_id` TEXT, `name` TEXT NOT NULL, `depth` INTEGER NOT NULL, `directory_path` TEXT NOT NULL, `document_uri` TEXT, `direct_track_count` INTEGER NOT NULL, `total_track_count` INTEGER NOT NULL, `name_sort_key` TEXT NOT NULL, PRIMARY KEY(`revision`, `node_id`))", + "fields": [ + { + "fieldPath": "revision", + "columnName": "revision", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nodeId", + "columnName": "node_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "folderId", + "columnName": "folder_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parentNodeId", + "columnName": "parent_node_id", + "affinity": "TEXT" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "depth", + "columnName": "depth", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "directoryPath", + "columnName": "directory_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "documentUri", + "columnName": "document_uri", + "affinity": "TEXT" + }, + { + "fieldPath": "directTrackCount", + "columnName": "direct_track_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalTrackCount", + "columnName": "total_track_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nameSortKey", + "columnName": "name_sort_key", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "revision", + "node_id" + ] + }, + "indices": [ + { + "name": "index_directory_summaries_revision_folder_id_parent_node_id_name_sort_key", + "unique": false, + "columnNames": [ + "revision", + "folder_id", + "parent_node_id", + "name_sort_key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_directory_summaries_revision_folder_id_parent_node_id_name_sort_key` ON `${TABLE_NAME}` (`revision`, `folder_id`, `parent_node_id`, `name_sort_key`)" + } + ] + }, + { + "tableName": "track_user_facts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`path` TEXT NOT NULL, `is_favorite` INTEGER NOT NULL, `play_count` INTEGER NOT NULL, `last_played_at` INTEGER, PRIMARY KEY(`path`))", + "fields": [ + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isFavorite", + "columnName": "is_favorite", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playCount", + "columnName": "play_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastPlayedAt", + "columnName": "last_played_at", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "path" + ] + } + }, + { + "tableName": "waveform_peaks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`track_path` TEXT NOT NULL, `bins` INTEGER NOT NULL, `peaks` BLOB NOT NULL, `created_at` INTEGER NOT NULL, PRIMARY KEY(`track_path`))", + "fields": [ + { + "fieldPath": "trackPath", + "columnName": "track_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bins", + "columnName": "bins", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "peaks", + "columnName": "peaks", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "track_path" + ] + } + }, + { + "tableName": "lyrics_cache", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`track_path` TEXT NOT NULL, `metadata_signature` TEXT, `status` TEXT NOT NULL, `source` TEXT, `provider` TEXT, `format` TEXT, `plain_lyrics` TEXT, `synced_lyrics` TEXT, `synced_lines_json` TEXT NOT NULL, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`track_path`))", + "fields": [ + { + "fieldPath": "trackPath", + "columnName": "track_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "metadataSignature", + "columnName": "metadata_signature", + "affinity": "TEXT" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT" + }, + { + "fieldPath": "provider", + "columnName": "provider", + "affinity": "TEXT" + }, + { + "fieldPath": "format", + "columnName": "format", + "affinity": "TEXT" + }, + { + "fieldPath": "plainLyrics", + "columnName": "plain_lyrics", + "affinity": "TEXT" + }, + { + "fieldPath": "syncedLyrics", + "columnName": "synced_lyrics", + "affinity": "TEXT" + }, + { + "fieldPath": "syncedLinesJson", + "columnName": "synced_lines_json", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "track_path" + ] + }, + "indices": [ + { + "name": "index_lyrics_cache_updated_at", + "unique": false, + "columnNames": [ + "updated_at" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_lyrics_cache_updated_at` ON `${TABLE_NAME}` (`updated_at`)" + } + ] + }, + { + "tableName": "track_fts", + "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`title` TEXT NOT NULL, `artist` TEXT NOT NULL, `album` TEXT NOT NULL, `file_name` TEXT NOT NULL, tokenize=unicode61)", + "fields": [ + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileName", + "columnName": "file_name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowid" + ] + }, + "ftsVersion": "FTS4", + "ftsOptions": { + "tokenizer": "unicode61", + "tokenizerArgs": [], + "contentTable": "", + "languageIdColumnName": "", + "matchInfo": "FTS4", + "notIndexedColumns": [], + "prefixSizes": [], + "preferredOrder": "ASC" + }, + "contentSyncTriggers": [] + } + ], + "views": [ + { + "viewName": "active_tracks", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT t.*\n FROM tracks t\n INNER JOIN catalog_sources s\n ON s.source_key = t.source_key\n AND s.active_generation_id = t.generation_id" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '518187e943df90394b038637134bc028')" + ] + } +} \ No newline at end of file diff --git a/modules/astra-library-scanner/android/src/androidTest/java/expo/modules/astralibraryscanner/data/ArtistCreditMetadataReaderTest.kt b/modules/astra-library-scanner/android/src/androidTest/java/expo/modules/astralibraryscanner/data/ArtistCreditMetadataReaderTest.kt new file mode 100644 index 0000000..54fa30b --- /dev/null +++ b/modules/astra-library-scanner/android/src/androidTest/java/expo/modules/astralibraryscanner/data/ArtistCreditMetadataReaderTest.kt @@ -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() + 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() + + 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() + + 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" + } +} diff --git a/modules/astra-library-scanner/android/src/androidTest/java/expo/modules/astralibraryscanner/data/CatalogMigrationTest.kt b/modules/astra-library-scanner/android/src/androidTest/java/expo/modules/astralibraryscanner/data/CatalogMigrationTest.kt new file mode 100644 index 0000000..5951cba --- /dev/null +++ b/modules/astra-library-scanner/android/src/androidTest/java/expo/modules/astralibraryscanner/data/CatalogMigrationTest.kt @@ -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(key, type, id), + ) + } + + private companion object { + const val TEST_DATABASE = "artist-credit-migration-test" + } +} diff --git a/modules/astra-library-scanner/android/src/androidTest/java/expo/modules/astralibraryscanner/data/RoomLibraryRepositoryTest.kt b/modules/astra-library-scanner/android/src/androidTest/java/expo/modules/astralibraryscanner/data/RoomLibraryRepositoryTest.kt index 4ff447d..f510d1c 100644 --- a/modules/astra-library-scanner/android/src/androidTest/java/expo/modules/astralibraryscanner/data/RoomLibraryRepositoryTest.kt +++ b/modules/astra-library-scanner/android/src/androidTest/java/expo/modules/astralibraryscanner/data/RoomLibraryRepositoryTest.kt @@ -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 = (0 until ALPHABET_SEED_SIZE).map { index -> diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraLibraryScannerModule.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraLibraryScannerModule.kt index 2d4a219..521fe42 100644 --- a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraLibraryScannerModule.kt +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraLibraryScannerModule.kt @@ -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().orEmpty(), album = this["album"] as? String, albumArtist = this["albumArtist"] as? String, + albumArtistNames = + (this["albumArtistNames"] as? List<*>)?.filterIsInstance().orEmpty(), genre = this["genre"] as? String, mimeType = this["mimeType"] as? String, durationMs = (this["durationMs"] as? Number)?.toLong(), diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/ArtistCredits.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/ArtistCredits.kt new file mode 100644 index 0000000..3b4572e --- /dev/null +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/ArtistCredits.kt @@ -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 = emptyList(), + val albumArtists: List = emptyList(), +) + +internal fun normalizeArtistNames(values: Iterable): List { + val result = LinkedHashMap() + 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 { + 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? { + val names = normalizeArtistNames(values) + return if (names.size > 1) JSONArray(names).toString() else null +} + +internal fun deserializeArtistNames(value: String?): List { + 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() + private val albumArtists = mutableListOf() + + 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() + } + } +} diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/AstraLibraryRepository.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/AstraLibraryRepository.kt index d4f3464..f364cf0 100644 --- a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/AstraLibraryRepository.kt +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/AstraLibraryRepository.kt @@ -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() diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/CatalogDatabase.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/CatalogDatabase.kt index 9fb1dd8..c1f1c2a 100644 --- a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/CatalogDatabase.kt +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/CatalogDatabase.kt @@ -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, directories: List, ftsRows: List, + 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'", + ) + } +} diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/CatalogEntities.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/CatalogEntities.kt index dc7901d..97cfc25 100644 --- a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/CatalogEntities.kt +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/CatalogEntities.kt @@ -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, diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/CatalogReadModelBuilder.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/CatalogReadModelBuilder.kt index 66ab8e9..6f2d8c7 100644 --- a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/CatalogReadModelBuilder.kt +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/CatalogReadModelBuilder.kt @@ -33,8 +33,10 @@ data class LocalAudioMetadata( val ok: Boolean, val title: String? = null, val artist: String? = null, + val artistNames: List = emptyList(), val album: String? = null, val albumArtist: String? = null, + val albumArtistNames: List = 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 { 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 { @@ -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" diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/LibraryModels.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/LibraryModels.kt index 27c2aaa..38426a4 100644 --- a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/LibraryModels.kt +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/LibraryModels.kt @@ -133,8 +133,10 @@ fun ActiveTrackView.toBridgeMap(): Map = 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, diff --git a/modules/astra-library-scanner/android/src/test/java/expo/modules/astralibraryscanner/data/ArtistCreditsTest.kt b/modules/astra-library-scanner/android/src/test/java/expo/modules/astralibraryscanner/data/ArtistCreditsTest.kt new file mode 100644 index 0000000..970c4ad --- /dev/null +++ b/modules/astra-library-scanner/android/src/test/java/expo/modules/astralibraryscanner/data/ArtistCreditsTest.kt @@ -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()) + } + +} diff --git a/modules/astra-library-scanner/index.ts b/modules/astra-library-scanner/index.ts index 7f84dfc..70e68f1 100644 --- a/modules/astra-library-scanner/index.ts +++ b/modules/astra-library-scanner/index.ts @@ -24,8 +24,12 @@ export interface ExtractedMetadata { error?: string; title?: string | null; artist?: string | null; + /** Ordered repeated ARTIST tag values; empty when the file has no multi-value credit. */ + artistNames?: string[]; album?: string | null; albumArtist?: string | null; + /** Ordered repeated ALBUMARTIST tag values; empty when not multi-valued. */ + albumArtistNames?: string[]; genre?: string | null; /** Container mime type reported by MediaMetadataRetriever. */ mimeType?: string | null; diff --git a/package.json b/package.json index 6c3df2c..06d88ab 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ "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: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: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", diff --git a/src/audio/artistCreditTransport.test.mts b/src/audio/artistCreditTransport.test.mts new file mode 100644 index 0000000..eca7a4e --- /dev/null +++ b/src/audio/artistCreditTransport.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); +}); diff --git a/src/audio/artistCreditTransport.ts b/src/audio/artistCreditTransport.ts new file mode 100644 index 0000000..d3b010d --- /dev/null +++ b/src/audio/artistCreditTransport.ts @@ -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; + } +} diff --git a/src/audio/sampleTracks.ts b/src/audio/sampleTracks.ts index 275242d..e6f3bd7 100644 --- a/src/audio/sampleTracks.ts +++ b/src/audio/sampleTracks.ts @@ -2,6 +2,10 @@ import type { Track as RntpTrack } from 'react-native-track-player'; import type { Track } from '@/types/audio'; import { streamUrlForTrack } from '@/services/remoteUrls'; import { artworkThumbFromSource, playerBackdropArtworkSource } from '@/library/artwork'; +import { + parseArtistCreditTransport, + serializeArtistCreditTransport, +} from './artistCreditTransport'; /** * 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, bitDepth: track.bitDepth, bitrate: track.bitrate, + astraArtistNamesJson: serializeArtistCreditTransport(track.artistNames), + astraAlbumArtist: track.albumArtist, + astraAlbumArtistNamesJson: serializeArtistCreditTransport(track.albumArtistNames), + astraAlbumIdentityKey: track.albumIdentityKey, astraPath: track.path, sourceType: track.sourceType, sourceId: track.sourceId, @@ -81,7 +89,13 @@ export function rntpToTrack(rt: RntpTrack): Track { path: astraPath ?? String(rt.url), title: rt.title ?? 'Unknown title', artist: rt.artist ?? 'Unknown artist', + artistNames: parseArtistCreditTransport(rt.astraArtistNamesJson), 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, artworkData: typeof rt.astraArtworkData === 'string' diff --git a/src/components/player/NowPlayingOverlay.tsx b/src/components/player/NowPlayingOverlay.tsx index 025e811..b8c6ecb 100644 --- a/src/components/player/NowPlayingOverlay.tsx +++ b/src/components/player/NowPlayingOverlay.tsx @@ -262,20 +262,23 @@ export function NowPlayingOverlay() { [libraryTracks, track] ); const artistName = track - ? resolveNavigationArtist( - libraryTrack ?? { artist: track.artist, album_artist: track.albumArtist ?? null }, + ? resolveNavigationArtist( + libraryTrack ?? { + artist: track.artist, + artist_names: track.artistNames, + album_artist: track.albumArtist ?? null, + album_artist_names: track.albumArtistNames, + }, artistGroupingMode ) : ''; const artistCreditTokens = useMemo(() => { if (!track) return []; - const collaborators = splitCollaborators(track.artist); - return buildArtistNameTokens( - collaborators.length > 0 ? collaborators : [track.artist] - ).map((token) => ({ - ...token, - separator: token.separator ? ', ' : null, - })); + const collaborators = + track.artistNames && track.artistNames.length > 0 + ? track.artistNames + : splitCollaborators(track.artist); + return buildArtistNameTokens(collaborators.length > 0 ? collaborators : [track.artist]); }, [track]); const albumKey = track?.albumIdentityKey ?? libraryTrack?.album_identity_key; diff --git a/src/components/player/nowPlayingPreferences.test.mts b/src/components/player/nowPlayingPreferences.test.mts index 55a861d..97ea6a6 100644 --- a/src/components/player/nowPlayingPreferences.test.mts +++ b/src/components/player/nowPlayingPreferences.test.mts @@ -24,3 +24,10 @@ test('builds separate clickable credits for collaborative track artists', () => { 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 }, + ]); +}); diff --git a/src/library/albumIdentity.test.mts b/src/library/albumIdentity.test.mts index efdb1f4..a6d071c 100644 --- a/src/library/albumIdentity.test.mts +++ b/src/library/albumIdentity.test.mts @@ -16,7 +16,9 @@ function createRow( id: overrides.id ?? nextId++, album: overrides.album, artist: overrides.artist, + artist_names: overrides.artist_names ?? [], album_artist: overrides.album_artist ?? null, + album_artist_names: overrides.album_artist_names ?? [], artwork_hash: overrides.artwork_hash ?? null, source_type: overrides.source_type ?? 'local', 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'); }); +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', () => { const rows = [ createRow({ id: 1, album: 'Split Release', artist: 'Artist A', artwork_hash: 'shared' }), diff --git a/src/library/albumIdentity.ts b/src/library/albumIdentity.ts index b584551..f0e3005 100644 --- a/src/library/albumIdentity.ts +++ b/src/library/albumIdentity.ts @@ -17,6 +17,7 @@ import { groupTracksByAlbumIdentity, normalizeDisplay, } from '../shared/library/albumGrouping.ts'; +import { formatArtistNames, normalizeArtistNames } from '../shared/library/artistCredits.ts'; export interface ProvisionalAlbumIdentity { key: string; @@ -32,11 +33,23 @@ export interface ProvisionalAlbumIdentity { export function buildProvisionalAlbumIdentity( albumArtist: string | null, artist: string, - album: string + album: string, + artistNames?: readonly string[] | null, + albumArtistNames?: readonly string[] | null ): ProvisionalAlbumIdentity { - const key = buildAlbumIdentityKeyFromTrack({ album, artist, album_artist: albumArtist }); - const normalizedAlbumArtist = normalizeDisplay(albumArtist ?? ''); - const displayArtist = normalizedAlbumArtist || getPrimaryArtistFromTrackArtist(artist); + const key = buildAlbumIdentityKeyFromTrack({ + album, + 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 }; } @@ -45,7 +58,9 @@ export interface AlbumIdentityRow { id: number; album: string; artist: string; + artist_names: string[]; album_artist: string | null; + album_artist_names: string[]; artwork_hash: string | null; source_type: string; artwork_source_id: string | null; @@ -72,7 +87,9 @@ export function computeAlbumIdentityUpdates( row, album: row.album, artist: row.artist, + artist_names: row.artist_names, album_artist: row.album_artist, + album_artist_names: row.album_artist_names, base_artwork_hash: row.artwork_hash ?? (row.source_type !== 'local' ? row.artwork_source_id : null), })); diff --git a/src/library/artistGrouping.test.mts b/src/library/artistGrouping.test.mts index fc2f63e..78be22c 100644 --- a/src/library/artistGrouping.test.mts +++ b/src/library/artistGrouping.test.mts @@ -14,7 +14,9 @@ function createTrack( const timestamp = nextTimestamp++; return { artist: overrides.artist, + artist_names: overrides.artist_names ?? null, album_artist: overrides.album_artist ?? null, + album_artist_names: overrides.album_artist_names ?? null, artwork_hash: overrides.artwork_hash ?? null, year: overrides.year ?? null, 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); }); +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', () => { const artists = buildArtistList([ createTrack({ artist: 'Primary Artist feat. Guest Artist' }), diff --git a/src/library/artistGrouping.ts b/src/library/artistGrouping.ts index 6eec910..b0d5f72 100644 --- a/src/library/artistGrouping.ts +++ b/src/library/artistGrouping.ts @@ -5,12 +5,13 @@ // every collaborator so featured artists are browsable. // 'fileTags' (desktop "strict"): use the tag verbatim (album_artist || artist). // -// Mobile has no parsed `artist_names_json` columns (MMR yields one artist string), -// so desktop's parsed-array paths collapse to splitCollaborators(artist) — which is -// the parsing heuristic. Everything here is derivable from artist + album_artist. +// Structured artist arrays are preferred when present so punctuation inside an +// individual name is never mistaken for a collaboration boundary. Legacy and +// remote rows still fall back to the original display-string heuristics. // Runtime imports stay relative so this module can run under plain `node --test`. import { normalizeDisplay, normalizeKey, splitCollaborators } from '../shared/library/albumGrouping.ts'; +import { normalizeArtistNames } from '../shared/library/artistCredits.ts'; import type { Artist, DbTrack } from '../types/library'; // 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'; /** 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, - '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). */ @@ -54,23 +62,27 @@ function dedupeByKey(parts: string[]): string[] { } /** File-tags artist: album_artist if present, else the raw track artist. */ -export function resolveStrictBrowseArtist(track: Pick): string { +export function resolveStrictBrowseArtist(track: ArtistCreditTrackLike): string { const albumArtist = normalizeDisplay(track.album_artist ?? ''); if (albumArtist) return albumArtist; return normalizeDisplay(track.artist) || UNKNOWN_ARTIST; } /** Astra-grouping primary: album_artist's first collaborator, else artist's first. */ -export function resolveCanonicalBrowseArtist(track: Pick): string { +export function resolveCanonicalBrowseArtist(track: ArtistCreditTrackLike): string { const albumArtist = normalizeDisplay(track.album_artist ?? ''); if (albumArtist) { + const parsedAlbumArtists = normalizeArtistNames(track.album_artist_names); + if (parsedAlbumArtists.length > 0) return parsedAlbumArtists[0]; 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; } /** Every artist a track is indexed under in astra mode: primary + all collaborators. */ -export function getCanonicalArtistIndexNames(track: Pick): string[] { +export function getCanonicalArtistIndexNames(track: ArtistCreditTrackLike): string[] { const unique = new Map(); const add = (name: string) => { const display = normalizeDisplay(name); @@ -81,10 +93,17 @@ export function getCanonicalArtistIndexNames(track: Pick 0 ? parsedTrackArtists : splitCollaborators(track.artist); for (const name of trackArtists) add(name); 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()); @@ -98,18 +117,18 @@ export function getCanonicalArtistIndexNames(track: Pick, + track: ArtistCreditTrackLike, mode: ArtistGroupingMode ): string { if (mode === 'fileTags') return resolveStrictBrowseArtist(track); const canonical = resolveCanonicalBrowseArtist(track); 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. */ export function trackMatchesBrowseArtist( - track: Pick, + track: ArtistCreditTrackLike, targetArtistKey: string, mode: ArtistGroupingMode ): boolean { @@ -125,9 +144,15 @@ export function trackMatchesBrowseArtist( const trackArtistKey = normalizeKey(track.artist); 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)) { return true; } + if (normalizeArtistNames(track.artist_names).some((n) => normalizeKey(n) === targetArtistKey)) { + return true; + } return splitCollaborators(track.artist).some((n) => normalizeKey(n) === targetArtistKey); } diff --git a/src/library/scanner.ts b/src/library/scanner.ts index 65d66aa..d65b813 100644 --- a/src/library/scanner.ts +++ b/src/library/scanner.ts @@ -34,6 +34,7 @@ type NativeFolder = LibraryFolder & { track_count: number; scan_status?: string; scan_error?: string | null; + needs_metadata_reindex?: boolean; }; function displayNameFromTreeUri(treeUri: string): string { diff --git a/src/library/trackAdapter.ts b/src/library/trackAdapter.ts index 177ca9d..046f1ef 100644 --- a/src/library/trackAdapter.ts +++ b/src/library/trackAdapter.ts @@ -25,8 +25,11 @@ export function dbTrackToTrack(track: DbTrack): Track { origin: 'library', title: track.title, artist: track.artist, + artistNames: track.artist_names?.length > 0 ? track.artist_names : undefined, album: track.album, albumArtist: track.album_artist ?? undefined, + albumArtistNames: + track.album_artist_names?.length > 0 ? track.album_artist_names : undefined, albumIdentityKey: track.album_identity_key, duration: track.duration, trackNumber: track.track_number ?? undefined, diff --git a/src/shared/library/albumGrouping.ts b/src/shared/library/albumGrouping.ts index 62709bd..8e633fc 100644 --- a/src/shared/library/albumGrouping.ts +++ b/src/shared/library/albumGrouping.ts @@ -1,8 +1,7 @@ // Port of desktop astra/src/shared/library/albumGrouping.ts — keep semantically -// identical so album identities match the desktop app. Mobile has no -// artist_names/*_names_json columns; those inputs stay undefined and the module -// falls through to the string-splitting paths, exactly like desktop does for -// files without multi-value tags. +// identical so album identities match the desktop app. Structured artist +// arrays are preferred; legacy and remote rows without them fall through to +// the same string-splitting paths desktop uses for single-value tags. // Explicit .ts extension so the module resolves under plain `node --test` // (Metro and tsc accept it via allowImportingTsExtensions). diff --git a/src/stores/libraryStore.ts b/src/stores/libraryStore.ts index 3b2687f..16c2df5 100644 --- a/src/stores/libraryStore.ts +++ b/src/stores/libraryStore.ts @@ -53,7 +53,10 @@ function persistSetting(key: string, value: string) { 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 { phase: 'idle' | 'discovering' | 'extracting' | 'analyzing'; @@ -487,7 +490,10 @@ export const useLibraryStore = create((set, get) => { await get().refresh(); 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(); } })().catch((error) => { diff --git a/src/types/library.ts b/src/types/library.ts index 5ef8c7a..ca1ebc9 100644 --- a/src/types/library.ts +++ b/src/types/library.ts @@ -9,8 +9,12 @@ export interface DbTrack { folder_id: number | null; // NULL for remote tracks (no SAF folder) title: string; artist: string; + /** Ordered repeated ARTIST tag values; empty for legacy/single-value rows. */ + artist_names: string[]; album: string; album_artist: string | null; + /** Ordered repeated ALBUMARTIST values; empty for legacy/single-value rows. */ + album_artist_names: string[]; album_identity_key: string; /** Settled group artist ("Various Artists" for shared-artwork compilations); * written by the album-identity recompute pass, NULL until it first runs. */