From a42e78017c054a056242fecfef3913b900dcc77c Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Fri, 31 Jul 2026 01:26:36 -0400 Subject: [PATCH] artist image search --- .env.example | 3 + PRIVACY.md | 7 +- .../4.json | 1168 +++++++++++++++++ .../ArtistArtworkImportTest.kt | 99 ++ .../data/RoomLibraryRepositoryTest.kt | 87 ++ .../data/UserMigrationTest.kt | 41 + .../ArtistArtworkImport.kt | 135 ++ .../AstraLibraryDataModule.kt | 66 + .../AstraLibraryScannerModule.kt | 47 +- .../data/AstraLibraryRepository.kt | 314 ++++- .../astralibraryscanner/data/LibraryModels.kt | 48 +- .../astralibraryscanner/data/UserDatabase.kt | 56 +- .../astralibraryscanner/data/UserEntities.kt | 20 + .../data/UserSnapshotStore.kt | 31 + modules/astra-library-scanner/index.ts | 80 ++ package-lock.json | 11 + package.json | 2 + src/app/(tabs)/library/artist/[name].tsx | 101 ++ src/app/_layout.tsx | 10 +- .../library/ArtistImageSearchSheet.tsx | 265 ++++ .../library/ArtistImageSweepStatus.tsx | 131 ++ src/components/library/CollapsingDetail.tsx | 4 +- .../ScanNotificationPermissionCard.tsx | 123 ++ .../ArtistImageDisclosurePrompt.tsx | 128 ++ src/components/onboarding/ArtistImageStep.tsx | 322 +++++ .../onboarding/NotificationStep.tsx | 195 +++ src/components/onboarding/OnboardingFlow.tsx | 93 +- src/components/onboarding/StepHeader.tsx | 67 + src/components/settings/SettingsPanels.tsx | 42 + src/library/artistGrouping.ts | 1 + src/library/artistImageLookup.ts | 326 +++++ src/library/artistImagePolicy.test.mts | 52 + src/library/artistImagePolicy.ts | 43 + src/library/nativePages.ts | 20 +- src/library/scanService.ts | 123 +- src/library/useScanNotificationPermission.ts | 64 + src/services/artistImages/cache.ts | 22 + src/services/artistImages/deezer.test.mts | 134 ++ src/services/artistImages/deezer.ts | 183 +++ src/stores/artistImageStore.ts | 51 + src/stores/libraryStore.ts | 13 + src/stores/settingsStore.ts | 39 + src/types/artistImages.ts | 49 + src/types/library.ts | 1 + 44 files changed, 4705 insertions(+), 112 deletions(-) create mode 100644 modules/astra-library-scanner/android/schemas/expo.modules.astralibraryscanner.data.AstraUserDatabase/4.json create mode 100644 modules/astra-library-scanner/android/src/androidTest/java/expo/modules/astralibraryscanner/ArtistArtworkImportTest.kt create mode 100644 modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/ArtistArtworkImport.kt create mode 100644 src/components/library/ArtistImageSearchSheet.tsx create mode 100644 src/components/library/ArtistImageSweepStatus.tsx create mode 100644 src/components/library/ScanNotificationPermissionCard.tsx create mode 100644 src/components/onboarding/ArtistImageDisclosurePrompt.tsx create mode 100644 src/components/onboarding/ArtistImageStep.tsx create mode 100644 src/components/onboarding/NotificationStep.tsx create mode 100644 src/components/onboarding/StepHeader.tsx create mode 100644 src/library/artistImageLookup.ts create mode 100644 src/library/artistImagePolicy.test.mts create mode 100644 src/library/artistImagePolicy.ts create mode 100644 src/library/useScanNotificationPermission.ts create mode 100644 src/services/artistImages/cache.ts create mode 100644 src/services/artistImages/deezer.test.mts create mode 100644 src/services/artistImages/deezer.ts create mode 100644 src/stores/artistImageStore.ts create mode 100644 src/types/artistImages.ts diff --git a/.env.example b/.env.example index 415cc47..92b5866 100644 --- a/.env.example +++ b/.env.example @@ -2,3 +2,6 @@ # protected GitHub release environment and in an ignored local .env file. EXPO_PUBLIC_LASTFM_API_KEY= EXPO_PUBLIC_LASTFM_SHARED_SECRET= +# Set to false to disable Deezer lookups without rolling back user data. +# Production releases still require Deezer approval/terms review. +EXPO_PUBLIC_DEEZER_ARTIST_IMAGES_ENABLED=true diff --git a/PRIVACY.md b/PRIVACY.md index 8510be3..82d1f24 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,6 +1,6 @@ # Privacy Policy — Astra -**Last updated: July 15, 2026** +**Last updated: July 30, 2026** ## Overview @@ -10,7 +10,7 @@ Most Astra data stays on your device. Optional features connect only when you ch ## Data stored on your device -Astra stores your selected library folders, indexed music metadata, playlists, playback history, preferences, cached artwork and lyrics, and optional service configuration on your device. +Astra stores your selected library folders, indexed music metadata, playlists, playback history, preferences, cached album and artist artwork, lyrics, and optional service configuration on your device. Artist images downloaded from a provider or chosen from a local file remain available offline until Astra's app data is cleared or the app is uninstalled. Passwords, session keys, and paired-desktop control tokens are stored using Android-backed secure storage. Other app data is stored in Astra's local database and files. @@ -19,6 +19,7 @@ Passwords, session keys, and paired-desktop control tokens are stored using Andr Depending on the features you enable, Astra may send data to the following destinations: - **Lyrics providers:** Track title, artist, album, and duration may be sent to LRCLIB or XLRCDB to find lyrics. +- **Deezer artist images:** If you enable automatic artist images or manually search Deezer, Astra sends the artist name to Deezer. The selected image and its provider identifier are stored locally. Automatic downloads default to Wi-Fi or Ethernet and can be disabled in Settings. - **Remote music servers:** If you add a Subsonic-compatible or Jellyfin server, Astra sends connection details, credentials, catalog requests, and playback requests directly to the server you configured. - **Scrobbling services:** If you enable Last.fm-compatible scrobbling, ListenBrainz, or another configured destination, Astra sends track and playback information to that service. - **Paired Astra Desktop:** If you pair a desktop, Astra exchanges remote-control, library-sync, playlist, favorite, queue, and playback data directly with that paired desktop. @@ -34,7 +35,7 @@ Astra may request: - **Folder and file access** through Android's system picker, so you can choose music folders and import or export supported files. - **Camera access** only when you open a QR-code scanner for desktop pairing, EQ presets, or Signal sharing. -- **Notification access** for playback controls, library scans, and paired-desktop sessions. +- **Notification access** for playback controls, library scans, and paired-desktop sessions. Scan notification access is requested only after you tap its explained permission button; scans can still run if it is skipped or denied. - **Local-network and internet access** for optional servers, scrobbling, lyrics, sharing, and desktop features. Astra does not request microphone access. diff --git a/modules/astra-library-scanner/android/schemas/expo.modules.astralibraryscanner.data.AstraUserDatabase/4.json b/modules/astra-library-scanner/android/schemas/expo.modules.astralibraryscanner.data.AstraUserDatabase/4.json new file mode 100644 index 0000000..d76aac5 --- /dev/null +++ b/modules/astra-library-scanner/android/schemas/expo.modules.astralibraryscanner.data.AstraUserDatabase/4.json @@ -0,0 +1,1168 @@ +{ + "formatVersion": 1, + "database": { + "version": 4, + "identityHash": "cd0e38fafdedee0a01cb80d23a5c35b2", + "entities": [ + { + "tableName": "settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + } + }, + { + "tableName": "folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `tree_uri` TEXT NOT NULL, `display_name` TEXT NOT NULL, `added_at` INTEGER NOT NULL, `last_scanned_at` INTEGER, `last_scan_status` TEXT NOT NULL, `last_scan_error` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "treeUri", + "columnName": "tree_uri", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addedAt", + "columnName": "added_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastScannedAt", + "columnName": "last_scanned_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastScanStatus", + "columnName": "last_scan_status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastScanError", + "columnName": "last_scan_error", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_folders_tree_uri", + "unique": true, + "columnNames": [ + "tree_uri" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_folders_tree_uri` ON `${TABLE_NAME}` (`tree_uri`)" + } + ] + }, + { + "tableName": "playlists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, `last_played_at` INTEGER, `kind` TEXT NOT NULL, `dynamic_rules_json` TEXT, `remote_source_id` INTEGER, `remote_playlist_id` TEXT, `sync_uid` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastPlayedAt", + "columnName": "last_played_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dynamicRulesJson", + "columnName": "dynamic_rules_json", + "affinity": "TEXT" + }, + { + "fieldPath": "remoteSourceId", + "columnName": "remote_source_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "remotePlaylistId", + "columnName": "remote_playlist_id", + "affinity": "TEXT" + }, + { + "fieldPath": "syncUid", + "columnName": "sync_uid", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_playlists_sync_uid", + "unique": true, + "columnNames": [ + "sync_uid" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_playlists_sync_uid` ON `${TABLE_NAME}` (`sync_uid`)" + }, + { + "name": "index_playlists_remote_source_id_remote_playlist_id", + "unique": true, + "columnNames": [ + "remote_source_id", + "remote_playlist_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_playlists_remote_source_id_remote_playlist_id` ON `${TABLE_NAME}` (`remote_source_id`, `remote_playlist_id`)" + }, + { + "name": "index_playlists_kind", + "unique": false, + "columnNames": [ + "kind" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlists_kind` ON `${TABLE_NAME}` (`kind`)" + } + ] + }, + { + "tableName": "playlist_tracks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `playlist_id` INTEGER NOT NULL, `track_path` TEXT NOT NULL, `position` INTEGER NOT NULL, `added_at` INTEGER NOT NULL, `fallback_title` TEXT, `fallback_artist` TEXT, `fallback_album` TEXT, FOREIGN KEY(`playlist_id`) REFERENCES `playlists`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlist_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "trackPath", + "columnName": "track_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addedAt", + "columnName": "added_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fallbackTitle", + "columnName": "fallback_title", + "affinity": "TEXT" + }, + { + "fieldPath": "fallbackArtist", + "columnName": "fallback_artist", + "affinity": "TEXT" + }, + { + "fieldPath": "fallbackAlbum", + "columnName": "fallback_album", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_playlist_tracks_playlist_id_position", + "unique": false, + "columnNames": [ + "playlist_id", + "position" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_tracks_playlist_id_position` ON `${TABLE_NAME}` (`playlist_id`, `position`)" + }, + { + "name": "index_playlist_tracks_playlist_id_track_path", + "unique": true, + "columnNames": [ + "playlist_id", + "track_path" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_playlist_tracks_playlist_id_track_path` ON `${TABLE_NAME}` (`playlist_id`, `track_path`)" + } + ], + "foreignKeys": [ + { + "table": "playlists", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlist_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "favorites", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`track_path` TEXT NOT NULL, `added_at` INTEGER NOT NULL, PRIMARY KEY(`track_path`))", + "fields": [ + { + "fieldPath": "trackPath", + "columnName": "track_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addedAt", + "columnName": "added_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "track_path" + ] + } + }, + { + "tableName": "playback_history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`track_path` TEXT NOT NULL, `last_played_at` INTEGER NOT NULL, `play_count` INTEGER NOT NULL, PRIMARY KEY(`track_path`))", + "fields": [ + { + "fieldPath": "trackPath", + "columnName": "track_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastPlayedAt", + "columnName": "last_played_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playCount", + "columnName": "play_count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "track_path" + ] + }, + "indices": [ + { + "name": "index_playback_history_last_played_at", + "unique": false, + "columnNames": [ + "last_played_at" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playback_history_last_played_at` ON `${TABLE_NAME}` (`last_played_at`)" + } + ] + }, + { + "tableName": "listening_history_meta", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `generation` TEXT NOT NULL, `started_at` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "generation", + "columnName": "generation", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startedAt", + "columnName": "started_at", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "listening_sessions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`session_key` TEXT NOT NULL, `generation` TEXT NOT NULL, `track_path` TEXT NOT NULL, `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, `artwork_hash` TEXT, `source_type` TEXT NOT NULL, `source_id` INTEGER, `artwork_source_id` TEXT, `duration_seconds` REAL NOT NULL, `started_at` INTEGER NOT NULL, `ended_at` INTEGER, `listened_seconds` REAL NOT NULL, `qualified_at` INTEGER, PRIMARY KEY(`session_key`))", + "fields": [ + { + "fieldPath": "sessionKey", + "columnName": "session_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "generation", + "columnName": "generation", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trackPath", + "columnName": "track_path", + "affinity": "TEXT", + "notNull": true + }, + { + "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": "artworkHash", + "columnName": "artwork_hash", + "affinity": "TEXT" + }, + { + "fieldPath": "sourceType", + "columnName": "source_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceId", + "columnName": "source_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "artworkSourceId", + "columnName": "artwork_source_id", + "affinity": "TEXT" + }, + { + "fieldPath": "durationSeconds", + "columnName": "duration_seconds", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "startedAt", + "columnName": "started_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endedAt", + "columnName": "ended_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "listenedSeconds", + "columnName": "listened_seconds", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "qualifiedAt", + "columnName": "qualified_at", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "session_key" + ] + }, + "indices": [ + { + "name": "index_listening_sessions_generation_started_at", + "unique": false, + "columnNames": [ + "generation", + "started_at" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_listening_sessions_generation_started_at` ON `${TABLE_NAME}` (`generation`, `started_at`)" + }, + { + "name": "index_listening_sessions_generation_qualified_at", + "unique": false, + "columnNames": [ + "generation", + "qualified_at" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_listening_sessions_generation_qualified_at` ON `${TABLE_NAME}` (`generation`, `qualified_at`)" + }, + { + "name": "index_listening_sessions_track_path", + "unique": false, + "columnNames": [ + "track_path" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_listening_sessions_track_path` ON `${TABLE_NAME}` (`track_path`)" + } + ] + }, + { + "tableName": "listening_segments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`session_key` TEXT NOT NULL, `segment_key` TEXT NOT NULL, `generation` TEXT NOT NULL, `started_at` INTEGER NOT NULL, `last_observed_at` INTEGER NOT NULL, `ended_at` INTEGER, `listened_seconds` REAL NOT NULL, PRIMARY KEY(`session_key`, `segment_key`), FOREIGN KEY(`session_key`) REFERENCES `listening_sessions`(`session_key`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionKey", + "columnName": "session_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "segmentKey", + "columnName": "segment_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "generation", + "columnName": "generation", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startedAt", + "columnName": "started_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastObservedAt", + "columnName": "last_observed_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endedAt", + "columnName": "ended_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "listenedSeconds", + "columnName": "listened_seconds", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "session_key", + "segment_key" + ] + }, + "indices": [ + { + "name": "index_listening_segments_generation_started_at_last_observed_at", + "unique": false, + "columnNames": [ + "generation", + "started_at", + "last_observed_at" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_listening_segments_generation_started_at_last_observed_at` ON `${TABLE_NAME}` (`generation`, `started_at`, `last_observed_at`)" + }, + { + "name": "index_listening_segments_session_key", + "unique": false, + "columnNames": [ + "session_key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_listening_segments_session_key` ON `${TABLE_NAME}` (`session_key`)" + } + ], + "foreignKeys": [ + { + "table": "listening_sessions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "session_key" + ], + "referencedColumns": [ + "session_key" + ] + } + ] + }, + { + "tableName": "remote_sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `type` TEXT NOT NULL, `name` TEXT NOT NULL, `base_url` TEXT NOT NULL, `username` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `last_status` TEXT NOT NULL, `last_error` TEXT, `last_sync_at` INTEGER, `last_checked_at` INTEGER, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "baseUrl", + "columnName": "base_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus", + "columnName": "last_status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastError", + "columnName": "last_error", + "affinity": "TEXT" + }, + { + "fieldPath": "lastSyncAt", + "columnName": "last_sync_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastCheckedAt", + "columnName": "last_checked_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_remote_sources_type_name", + "unique": false, + "columnNames": [ + "type", + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_remote_sources_type_name` ON `${TABLE_NAME}` (`type`, `name`)" + } + ] + }, + { + "tableName": "favorite_tombstones", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sync_key` TEXT NOT NULL, `deleted_at` INTEGER NOT NULL, PRIMARY KEY(`sync_key`))", + "fields": [ + { + "fieldPath": "syncKey", + "columnName": "sync_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deleted_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sync_key" + ] + } + }, + { + "tableName": "favorite_sync_pending", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sync_key` TEXT NOT NULL, `title` TEXT NOT NULL, `artist` TEXT NOT NULL, `album` TEXT NOT NULL, `added_at` INTEGER NOT NULL, PRIMARY KEY(`sync_key`))", + "fields": [ + { + "fieldPath": "syncKey", + "columnName": "sync_key", + "affinity": "TEXT", + "notNull": true + }, + { + "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": "addedAt", + "columnName": "added_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sync_key" + ] + } + }, + { + "tableName": "playlist_tombstones", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sync_uid` TEXT NOT NULL, `deleted_at` INTEGER NOT NULL, PRIMARY KEY(`sync_uid`))", + "fields": [ + { + "fieldPath": "syncUid", + "columnName": "sync_uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deleted_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sync_uid" + ] + } + }, + { + "tableName": "playlist_sync_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sync_uid` TEXT NOT NULL, `local_updated_at` INTEGER NOT NULL, `remote_updated_at` INTEGER NOT NULL, PRIMARY KEY(`sync_uid`))", + "fields": [ + { + "fieldPath": "syncUid", + "columnName": "sync_uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "localUpdatedAt", + "columnName": "local_updated_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteUpdatedAt", + "columnName": "remote_updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sync_uid" + ] + } + }, + { + "tableName": "playback_sessions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `context_json` TEXT NOT NULL, `anchor_path` TEXT, `shuffle_seed` INTEGER, `active_position` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, `queue_revision` INTEGER NOT NULL, `catalog_revision` INTEGER, `is_dirty` INTEGER NOT NULL, `next_entry_id` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contextJson", + "columnName": "context_json", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "anchorPath", + "columnName": "anchor_path", + "affinity": "TEXT" + }, + { + "fieldPath": "shuffleSeed", + "columnName": "shuffle_seed", + "affinity": "INTEGER" + }, + { + "fieldPath": "activePosition", + "columnName": "active_position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "queueRevision", + "columnName": "queue_revision", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "catalogRevision", + "columnName": "catalog_revision", + "affinity": "INTEGER" + }, + { + "fieldPath": "isDirty", + "columnName": "is_dirty", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nextEntryId", + "columnName": "next_entry_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "playback_queue_entries", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`session_id` TEXT NOT NULL, `position` INTEGER NOT NULL, `track_path` TEXT NOT NULL, `entry_id` INTEGER NOT NULL, PRIMARY KEY(`session_id`, `entry_id`), FOREIGN KEY(`session_id`) REFERENCES `playback_sessions`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "session_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "trackPath", + "columnName": "track_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "entryId", + "columnName": "entry_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "session_id", + "entry_id" + ] + }, + "indices": [ + { + "name": "index_playback_queue_entries_session_id_position", + "unique": true, + "columnNames": [ + "session_id", + "position" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_playback_queue_entries_session_id_position` ON `${TABLE_NAME}` (`session_id`, `position`)" + }, + { + "name": "index_playback_queue_entries_session_id_track_path", + "unique": false, + "columnNames": [ + "session_id", + "track_path" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playback_queue_entries_session_id_track_path` ON `${TABLE_NAME}` (`session_id`, `track_path`)" + } + ], + "foreignKeys": [ + { + "table": "playback_sessions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "session_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "playback_original_queue_entries", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`session_id` TEXT NOT NULL, `position` INTEGER NOT NULL, `track_path` TEXT NOT NULL, `entry_id` INTEGER NOT NULL, PRIMARY KEY(`session_id`, `entry_id`), FOREIGN KEY(`session_id`) REFERENCES `playback_sessions`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "session_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "trackPath", + "columnName": "track_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "entryId", + "columnName": "entry_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "session_id", + "entry_id" + ] + }, + "indices": [ + { + "name": "index_playback_original_queue_entries_session_id_position", + "unique": true, + "columnNames": [ + "session_id", + "position" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_playback_original_queue_entries_session_id_position` ON `${TABLE_NAME}` (`session_id`, `position`)" + }, + { + "name": "index_playback_original_queue_entries_session_id_track_path", + "unique": false, + "columnNames": [ + "session_id", + "track_path" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playback_original_queue_entries_session_id_track_path` ON `${TABLE_NAME}` (`session_id`, `track_path`)" + } + ], + "foreignKeys": [ + { + "table": "playback_sessions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "session_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "snapshot_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `last_snapshot_at` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSnapshotAt", + "columnName": "last_snapshot_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "artist_images", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`grouping_mode` TEXT NOT NULL, `artist_key` TEXT NOT NULL, `artist_name` TEXT NOT NULL, `manual_image_hash` TEXT, `automatic_image_hash` TEXT, `automatic_provider` TEXT, `automatic_source_id` TEXT, `lookup_status` TEXT NOT NULL, `retry_count` INTEGER NOT NULL, `last_attempt_at` INTEGER, `next_retry_at` INTEGER, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`grouping_mode`, `artist_key`))", + "fields": [ + { + "fieldPath": "groupingMode", + "columnName": "grouping_mode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistKey", + "columnName": "artist_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistName", + "columnName": "artist_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "manualImageHash", + "columnName": "manual_image_hash", + "affinity": "TEXT" + }, + { + "fieldPath": "automaticImageHash", + "columnName": "automatic_image_hash", + "affinity": "TEXT" + }, + { + "fieldPath": "automaticProvider", + "columnName": "automatic_provider", + "affinity": "TEXT" + }, + { + "fieldPath": "automaticSourceId", + "columnName": "automatic_source_id", + "affinity": "TEXT" + }, + { + "fieldPath": "lookupStatus", + "columnName": "lookup_status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "retryCount", + "columnName": "retry_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAttemptAt", + "columnName": "last_attempt_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "nextRetryAt", + "columnName": "next_retry_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "grouping_mode", + "artist_key" + ] + }, + "indices": [ + { + "name": "index_artist_images_lookup_status_next_retry_at", + "unique": false, + "columnNames": [ + "lookup_status", + "next_retry_at" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artist_images_lookup_status_next_retry_at` ON `${TABLE_NAME}` (`lookup_status`, `next_retry_at`)" + } + ] + } + ], + "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, 'cd0e38fafdedee0a01cb80d23a5c35b2')" + ] + } +} \ No newline at end of file diff --git a/modules/astra-library-scanner/android/src/androidTest/java/expo/modules/astralibraryscanner/ArtistArtworkImportTest.kt b/modules/astra-library-scanner/android/src/androidTest/java/expo/modules/astralibraryscanner/ArtistArtworkImportTest.kt new file mode 100644 index 0000000..dc98131 --- /dev/null +++ b/modules/astra-library-scanner/android/src/androidTest/java/expo/modules/astralibraryscanner/ArtistArtworkImportTest.kt @@ -0,0 +1,99 @@ +package expo.modules.astralibraryscanner + +import android.content.Context +import android.graphics.Bitmap +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.File +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ArtistArtworkImportTest { + private lateinit var root: File + private lateinit var artwork: File + private lateinit var thumbnails: File + private lateinit var cache: ImportedArtworkCache + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + root = File(context.cacheDir, "artist-artwork-import-test").apply { + deleteRecursively() + mkdirs() + } + artwork = File(root, "artwork") + thumbnails = File(root, "thumbnails") + cache = ImportedArtworkCache(artwork, thumbnails) + } + + @After + fun cleanUp() { + root.deleteRecursively() + } + + @Test + fun supportedImagesAreContentAddressedReusedAndThumbnailed() { + val formats = listOf( + Bitmap.CompressFormat.JPEG to ".jpg", + Bitmap.CompressFormat.PNG to ".png", + Bitmap.CompressFormat.WEBP_LOSSLESS to ".webp", + ) + for ((format, extension) in formats) { + val bytes = imageBytes(format) + val first = cache.cache(bytes) + val modifiedAt = File(artwork, first).lastModified() + val second = cache.cache(bytes) + + assertEquals(first, second) + assertTrue(first.endsWith(extension)) + assertTrue(File(artwork, first).isFile) + assertEquals(modifiedAt, File(artwork, second).lastModified()) + assertTrue(File(thumbnails, "${first.substringBeforeLast('.')}.jpg").isFile) + } + } + + @Test + fun emptyCorruptAndOversizedInputsAreRejectedWithoutPublishingFiles() { + assertFails { cache.cache(byteArrayOf()) } + assertFails { cache.cache("not an image".toByteArray()) } + assertFails { + readImportedArtworkBytes( + ByteArrayInputStream(ByteArray(33)), + maximumBytes = 32, + ) + } + assertTrue(artwork.listFiles().isNullOrEmpty()) + assertTrue(thumbnails.listFiles().isNullOrEmpty()) + } + + private fun imageBytes(format: Bitmap.CompressFormat): ByteArray { + val bitmap = Bitmap.createBitmap(320, 200, Bitmap.Config.ARGB_8888) + return try { + ByteArrayOutputStream().use { output -> + assertTrue(bitmap.compress(format, 90, output)) + output.toByteArray() + } + } finally { + bitmap.recycle() + } + } + + private fun assertFails(block: () -> Unit) { + try { + block() + fail("Expected image import to fail") + } catch (_: IllegalArgumentException) { + // Expected validation failure. + } catch (_: IllegalStateException) { + // Expected validation failure. + } + } +} 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 864dd5a..9469770 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 @@ -368,6 +368,8 @@ class RoomLibraryRepositoryTest { val valid = snapshots.newestValid() assertTrue(valid != null) + // A pre-feature v1 snapshot has no artistImages property. + valid?.payload?.remove("artistImages") val replacement = Room.inMemoryDatabaseBuilder(context, AstraUserDatabase::class.java) .allowMainThreadQueries() .build() @@ -380,6 +382,91 @@ class RoomLibraryRepositoryTest { } } + @Test + fun artistImageSnapshotRoundTripPreservesManualAndAutomaticLayers() = runBlocking { + val context = ApplicationProvider.getApplicationContext() + val snapshotDirectory = context.filesDir.resolve("astra-user-snapshots") + snapshotDirectory.deleteRecursively() + val snapshots = UserSnapshotStore(context) + user.userDao().putArtistImage( + ArtistImageEntity( + groupingMode = "fileTags", + artistKey = "björk", + artistName = "Björk", + manualImageHash = "manual.webp", + automaticImageHash = "deezer.jpg", + automaticProvider = "deezer", + automaticSourceId = "42", + lookupStatus = "transient_error", + retryCount = 2, + lastAttemptAt = 10, + nextRetryAt = 30, + updatedAt = 20, + ), + ) + snapshots.write(user) + + val replacement = Room.inMemoryDatabaseBuilder(context, AstraUserDatabase::class.java) + .allowMainThreadQueries() + .build() + try { + snapshots.restore(replacement, requireNotNull(snapshots.newestValid())) + val restored = replacement.userDao().getArtistImage("fileTags", "björk") + assertEquals("manual.webp", restored?.manualImageHash) + assertEquals("deezer.jpg", restored?.automaticImageHash) + assertEquals("42", restored?.automaticSourceId) + assertEquals("transient_error", restored?.lookupStatus) + assertEquals(2, restored?.retryCount) + assertEquals(30L, restored?.nextRetryAt) + } finally { + replacement.close() + snapshotDirectory.deleteRecursively() + } + } + + @Test + fun artistArtworkBridgeUsesManualThenDeezerThenTrack() { + val summary = ArtistSummaryEntity( + revision = 1, + artistKey = "björk", + artist = "Björk", + groupingMode = "astra", + trackCount = 2, + primaryTrackCount = 2, + albumCount = 2, + artworkHash = "track.jpg", + nameSortKey = "bjork", + sectionLabel = "B", + isCollaboration = false, + artworkHashesJson = """["track.jpg","other.jpg"]""", + ) + val automatic = ArtistImageEntity( + groupingMode = "astra", + artistKey = "björk", + artistName = "Björk", + automaticImageHash = "deezer.jpg", + automaticProvider = "deezer", + lookupStatus = "found", + updatedAt = 1, + ) + + val automaticMap = summary.toBridgeMap(automatic) + assertEquals("deezer.jpg", automaticMap["artwork_hash"]) + assertEquals(listOf("deezer.jpg"), automaticMap["artwork_hashes"]) + assertEquals("deezer", automaticMap["artwork_source"]) + + val manualMap = summary.toBridgeMap( + automatic.copy(manualImageHash = "manual.webp"), + ) + assertEquals("manual.webp", manualMap["artwork_hash"]) + assertEquals(listOf("manual.webp"), manualMap["artwork_hashes"]) + assertEquals("manual", manualMap["artwork_source"]) + + val trackMap = summary.toBridgeMap() + assertEquals("track.jpg", trackMap["artwork_hash"]) + assertEquals("track", trackMap["artwork_source"]) + } + @Test fun dynamicRulesUseBoundArgumentsAndEscapeWildcards() = runBlocking { publish( diff --git a/modules/astra-library-scanner/android/src/androidTest/java/expo/modules/astralibraryscanner/data/UserMigrationTest.kt b/modules/astra-library-scanner/android/src/androidTest/java/expo/modules/astralibraryscanner/data/UserMigrationTest.kt index 6006bfd..6a1c8cf 100644 --- a/modules/astra-library-scanner/android/src/androidTest/java/expo/modules/astralibraryscanner/data/UserMigrationTest.kt +++ b/modules/astra-library-scanner/android/src/androidTest/java/expo/modules/astralibraryscanner/data/UserMigrationTest.kt @@ -181,6 +181,47 @@ class UserMigrationTest { ) } + @Test + fun artistImageV4MigrationPreservesUserDataAndCreatesRetryIndex() { + helper.createDatabase(TEST_DATABASE, 3).apply { + execSQL("INSERT INTO settings (`key`, value) VALUES ('theme_base', 'dark')") + close() + } + + val database = helper.runMigrationsAndValidate( + TEST_DATABASE, + 4, + true, + USER_MIGRATION_3_4, + ) + + assertEquals("dark", database.singleString("SELECT value FROM settings WHERE `key` = 'theme_base'")) + database.execSQL( + """ + INSERT INTO artist_images ( + grouping_mode, artist_key, artist_name, manual_image_hash, + automatic_image_hash, automatic_provider, automatic_source_id, + lookup_status, retry_count, last_attempt_at, next_retry_at, updated_at + ) VALUES ( + 'astra', 'björk', 'Björk', 'manual.jpg', + 'deezer.jpg', 'deezer', '42', + 'found', 0, 10, NULL, 20 + ) + """.trimIndent(), + ) + assertEquals(1, database.singleInt("SELECT COUNT(*) FROM artist_images")) + assertEquals( + 1, + database.singleInt( + """ + SELECT COUNT(*) FROM sqlite_master + WHERE type = 'index' + AND name = 'index_artist_images_lookup_status_next_retry_at' + """.trimIndent(), + ), + ) + } + private fun androidx.sqlite.db.SupportSQLiteDatabase.singleString(query: String): String = this.query(query).use { cursor -> assertTrue(cursor.moveToFirst()) diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/ArtistArtworkImport.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/ArtistArtworkImport.kt new file mode 100644 index 0000000..a3044a7 --- /dev/null +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/ArtistArtworkImport.kt @@ -0,0 +1,135 @@ +package expo.modules.astralibraryscanner + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.FileOutputStream +import java.io.InputStream +import java.security.MessageDigest +import kotlin.math.max +import kotlin.math.roundToInt + +internal const val MAX_IMPORTED_ARTWORK_BYTES = 12 * 1024 * 1024 + +internal fun readImportedArtworkBytes( + input: InputStream, + maximumBytes: Int = MAX_IMPORTED_ARTWORK_BYTES, +): ByteArray { + val output = ByteArrayOutputStream(minOf(maximumBytes, 64 * 1024)) + val buffer = ByteArray(32 * 1024) + var total = 0 + while (true) { + val read = input.read(buffer) + if (read < 0) break + total += read + require(total <= maximumBytes) { "Choose an image smaller than 12 MB" } + output.write(buffer, 0, read) + } + return output.toByteArray() +} + +internal class ImportedArtworkCache( + private val artworkDirectory: File, + private val thumbnailDirectory: File, + private val thumbnailSize: Int = 128, +) { + fun cache(bytes: ByteArray): String { + require(bytes.isNotEmpty()) { "The selected image is empty" } + val extension = supportedExtension(bytes) + ?: error("Choose a JPEG, PNG, or WebP image") + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) + require(bounds.outWidth > 0 && bounds.outHeight > 0) { + "The selected file is not a valid image" + } + require(bounds.outWidth <= 16_384 && bounds.outHeight <= 16_384) { + "The selected image dimensions are too large" + } + + artworkDirectory.mkdirs() + thumbnailDirectory.mkdirs() + val fileName = md5Hex(bytes) + extension + publishAtomically(bytes, File(artworkDirectory, fileName)) + publishThumbnail(bytes, fileName) + return fileName + } + + private fun publishAtomically(bytes: ByteArray, target: File) { + if (target.isFile) return + val temporary = File(artworkDirectory, "${target.name}.tmp-${System.nanoTime()}") + try { + FileOutputStream(temporary).use { output -> + output.write(bytes) + output.fd.sync() + } + if (!temporary.renameTo(target) && !target.isFile) { + error("The image could not be cached") + } + } finally { + if (temporary.exists()) temporary.delete() + } + } + + private fun publishThumbnail(bytes: ByteArray, artworkHash: String) { + val target = File( + thumbnailDirectory, + "${artworkHash.substringBeforeLast('.', artworkHash)}.jpg", + ) + if (target.isFile) return + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) + val options = BitmapFactory.Options().apply { + var sample = 1 + while (max(bounds.outWidth, bounds.outHeight) / sample > thumbnailSize * 2) sample *= 2 + inSampleSize = sample + inPreferredConfig = Bitmap.Config.RGB_565 + } + val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options) + ?: error("The selected file is not a valid image") + val largest = max(decoded.width, decoded.height) + val thumbnail = if (largest <= thumbnailSize) { + decoded + } else { + val scale = thumbnailSize.toFloat() / largest + Bitmap.createScaledBitmap( + decoded, + max(1, (decoded.width * scale).roundToInt()), + max(1, (decoded.height * scale).roundToInt()), + true, + ) + } + val temporary = File(thumbnailDirectory, "${target.name}.tmp-${System.nanoTime()}") + try { + FileOutputStream(temporary).use { output -> + require(thumbnail.compress(Bitmap.CompressFormat.JPEG, 84, output)) { + "The artist image thumbnail could not be created" + } + output.fd.sync() + } + if (!temporary.renameTo(target) && !target.isFile) { + error("The artist image thumbnail could not be cached") + } + } finally { + if (temporary.exists()) temporary.delete() + if (thumbnail !== decoded && !decoded.isRecycled) decoded.recycle() + if (!thumbnail.isRecycled) thumbnail.recycle() + } + } + + private fun supportedExtension(bytes: ByteArray): String? = when { + bytes.size >= 2 && bytes[0] == 0xFF.toByte() && bytes[1] == 0xD8.toByte() -> ".jpg" + bytes.size >= 8 && + bytes[0] == 0x89.toByte() && bytes[1] == 0x50.toByte() && + bytes[2] == 0x4E.toByte() && bytes[3] == 0x47.toByte() -> ".png" + bytes.size >= 12 && + bytes[0] == 'R'.code.toByte() && bytes[1] == 'I'.code.toByte() && + bytes[2] == 'F'.code.toByte() && bytes[3] == 'F'.code.toByte() && + bytes[8] == 'W'.code.toByte() && bytes[9] == 'E'.code.toByte() && + bytes[10] == 'B'.code.toByte() && bytes[11] == 'P'.code.toByte() -> ".webp" + else -> null + } + + private fun md5Hex(bytes: ByteArray): String = + MessageDigest.getInstance("MD5").digest(bytes).joinToString("") { "%02x".format(it) } +} diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraLibraryDataModule.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraLibraryDataModule.kt index 4a9a784..f56e7e0 100644 --- a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraLibraryDataModule.kt +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraLibraryDataModule.kt @@ -25,6 +25,7 @@ class AstraLibraryDataModule : Module() { "onLibraryStatus", "onScanProgress", "onCatalogChanged", + "onArtistImagesChanged", ) OnCreate { @@ -467,6 +468,71 @@ class AstraLibraryDataModule : Module() { repository().getArtistAlbums(artistKey, groupingMode, offset, limit) } + AsyncFunction("getPendingArtistImageLookups") Coroutine { limit: Int, now: Double -> + repository().getPendingArtistImageLookups(limit, now.toLong()) + } + + AsyncFunction("clearArtistImageLookupFailures").Coroutine { + repository().clearArtistImageLookupFailures().toDouble() + } + + AsyncFunction("getArtistImageStats") Coroutine { groupingMode: String, now: Double -> + repository().getArtistImageStats(groupingMode, now.toLong()) + } + + AsyncFunction("getArtistImageState") Coroutine { artistKey: String, groupingMode: String -> + repository().getArtistImageState(artistKey, groupingMode) + } + + AsyncFunction("recordArtistImageLookup") Coroutine { + artistKey: String, + artistName: String, + groupingMode: String, + values: Map, + -> + repository().recordArtistImageLookup( + artistKey = artistKey, + artistName = artistName, + groupingMode = groupingMode, + status = values["status"] as? String ?: "transient_error", + automaticImageHash = values["automaticImageHash"] as? String, + provider = values["provider"] as? String, + sourceId = values["sourceId"] as? String, + attemptedAt = (values["attemptedAt"] as? Number)?.toLong() ?: System.currentTimeMillis(), + nextRetryAt = (values["nextRetryAt"] as? Number)?.toLong(), + clearManual = values["clearManual"] as? Boolean ?: false, + ) + sendEvent( + "onArtistImagesChanged", + mapOf("artistKey" to artistKey, "groupingMode" to groupingMode), + ) + } + + AsyncFunction("setManualArtistImage") Coroutine { + artistKey: String, + artistName: String, + groupingMode: String, + artworkHash: String, + -> + repository().setManualArtistImage(artistKey, artistName, groupingMode, artworkHash) + sendEvent( + "onArtistImagesChanged", + mapOf("artistKey" to artistKey, "groupingMode" to groupingMode), + ) + } + + AsyncFunction("clearManualArtistImage") Coroutine { + artistKey: String, + artistName: String, + groupingMode: String, + -> + repository().clearManualArtistImage(artistKey, artistName, groupingMode) + sendEvent( + "onArtistImagesChanged", + mapOf("artistKey" to artistKey, "groupingMode" to groupingMode), + ) + } + AsyncFunction("searchTracks") Coroutine { query: String, limit: Int -> repository().searchTracks(query, limit) } 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 521fe42..4513674 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 @@ -49,6 +49,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext @@ -266,6 +267,10 @@ class AstraLibraryScannerModule : Module() { withContext(Dispatchers.IO) { ensureArtworkThumbnails(hashes) } } + AsyncFunction("cacheArtworkFromUri") Coroutine { uri: String -> + withContext(Dispatchers.IO) { cacheArtworkFromUri(uri) } + } + Function("getPersistedTreeUris") { requireContext().contentResolver.persistedUriPermissions .filter { it.isReadPermission } @@ -308,6 +313,20 @@ class AstraLibraryScannerModule : Module() { Function("stopScanService") { ScanForegroundService.stop(requireContext()) } + + /** + * A wait that still elapses while Astra is backgrounded. + * + * React Native drives `setTimeout` from a Choreographer frame callback that + * `JavaTimerManager.onHostPause()` removes, so JS timers simply stop firing + * once the activity is paused — a foreground service keeps the process alive + * but does not bring them back. Work that must pace itself across a + * backgrounded stretch has to wait on native instead, because a promise + * resolved from here reaches the JS thread without the frame callback. + */ + AsyncFunction("backgroundDelay") Coroutine { milliseconds: Int -> + delay(milliseconds.toLong().coerceIn(0L, 60_000L)) + } } private fun requireContext(): Context = @@ -1431,6 +1450,18 @@ class AstraLibraryScannerModule : Module() { return fileName } + private fun cacheArtworkFromUri(rawUri: String): String { + val uri = Uri.parse(rawUri.trim().ifEmpty { error("An image URI is required") }) + val bytes = requireContext().contentResolver.openInputStream(uri) + ?.use(::readImportedArtworkBytes) + ?: error("The selected image could not be opened") + return ImportedArtworkCache( + artworkDirectory = artworkDir(), + thumbnailDirectory = artworkThumbDir(), + thumbnailSize = artworkThumbSize, + ).cache(bytes) + } + private fun ensureArtworkThumbnails(hashes: List): Int { var generated = 0 val seen = mutableSetOf() @@ -1533,11 +1564,19 @@ class AstraLibraryScannerModule : Module() { private fun md5Hex(bytes: ByteArray): String = MessageDigest.getInstance("MD5").digest(bytes).joinToString("") { "%02x".format(it) } - private fun sniffImageExtension(bytes: ByteArray): String = when { + private fun sniffSupportedImageExtension(bytes: ByteArray): String? = when { bytes.size >= 2 && bytes[0] == 0xFF.toByte() && bytes[1] == 0xD8.toByte() -> ".jpg" - bytes.size >= 4 && bytes[0] == 0x89.toByte() && bytes[1] == 0x50.toByte() -> ".png" - bytes.size >= 12 && bytes[8] == 'W'.code.toByte() && bytes[9] == 'E'.code.toByte() && + bytes.size >= 8 && + bytes[0] == 0x89.toByte() && bytes[1] == 0x50.toByte() && + bytes[2] == 0x4E.toByte() && bytes[3] == 0x47.toByte() -> ".png" + bytes.size >= 12 && + bytes[0] == 'R'.code.toByte() && bytes[1] == 'I'.code.toByte() && + bytes[2] == 'F'.code.toByte() && bytes[3] == 'F'.code.toByte() && + bytes[8] == 'W'.code.toByte() && bytes[9] == 'E'.code.toByte() && bytes[10] == 'B'.code.toByte() && bytes[11] == 'P'.code.toByte() -> ".webp" - else -> ".jpg" + else -> null } + + private fun sniffImageExtension(bytes: ByteArray): String = + sniffSupportedImageExtension(bytes) ?: ".jpg" } 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 828ebd9..dd270c8 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 @@ -2047,7 +2047,7 @@ class AstraLibraryRepository private constructor( } val next = rows.lastOrNull()?.let { row -> artistCursor(revision, kind, sort, row).encode() } mapOf( - "items" to rows.map(ArtistSummaryEntity::toBridgeMap), + "items" to bridgeArtistSummaries(rows), "nextCursor" to next, "previousCursor" to null, "totalCount" to dao.countArtists(revision, mode, includeCollaborations).toDouble(), @@ -2085,7 +2085,7 @@ class AstraLibraryRepository private constructor( ?.lastOrNull() ?.let { row -> artistCursor(revision, kind, sort, row).encode() } mapOf( - "items" to descending.reversed().map(ArtistSummaryEntity::toBridgeMap), + "items" to bridgeArtistSummaries(descending.reversed()), "nextCursor" to null, "previousCursor" to previous, "totalCount" to dao.countArtists(revision, mode, includeCollaborations).toDouble(), @@ -2187,7 +2187,9 @@ class AstraLibraryRepository private constructor( ).encode() } mapOf( - "summary" to dao.getArtistSummary(revision, mode, normalizedArtistKey)?.toBridgeMap(), + "summary" to bridgeArtistSummary( + dao.getArtistSummary(revision, mode, normalizedArtistKey), + ), "items" to rows.map(ActiveTrackView::toBridgeMap), "nextCursor" to next, "previousCursor" to null, @@ -2269,10 +2271,312 @@ class AstraLibraryRepository private constructor( mapOf( "tracks" to tracks.map(ActiveTrackView::toBridgeMap), "albums" to albums.map(AlbumSummaryEntity::toBridgeMap), - "artists" to artists.map(ArtistSummaryEntity::toBridgeMap), + "artists" to bridgeArtistSummaries(artists), ) } + /** + * The single rule for "this artist still needs a lookup". `not_found` and + * `found` are both terminal here — see [clearArtistImageLookupFailures] for + * how unmatched artists become eligible again. + */ + private fun isArtistImagePending(image: ArtistImageEntity?, now: Long): Boolean = + image == null || + image.lookupStatus == "never" || + (image.lookupStatus == "transient_error" && (image.nextRetryAt ?: 0L) <= now) + + /** + * Both numbers the artist-image UI needs, from one pass over the catalog. + * + * `pending` spans both grouping modes because the sweep does, and is counted + * by normalized name rather than by row: the caller batches same-name artists + * into one provider request, and each artist is listed under both modes, so a + * row count would roughly double the real amount of work. + * + * `missing` is scoped to [groupingMode] instead, because it is shown to the + * user and has to match the artist list they are actually looking at. It + * counts artists with no portrait from any source, so it includes the ones + * already written off as `not_found` — those are exactly the candidates for a + * retry sweep, and `pending` deliberately excludes them. + */ + suspend fun getArtistImageStats( + groupingMode: String, + now: Long, + ): Map = withCatalogRecovery { database -> + val revision = database.catalogDao().getRevision() + val mode = normalizeGroupingMode(groupingMode) + val byMode = listOf("astra", "fileTags").associateWith { entry -> + database.catalogDao().getAllArtistSummaries(revision, entry) + } + if (byMode.values.all { it.isEmpty() }) { + return@withCatalogRecovery mapOf("pending" to 0, "missing" to 0) + } + + val images = requireUser().userDao().getAllArtistImages() + .associateBy { artistImageMapKey(it.groupingMode, it.artistKey) } + fun named(summaries: List, keep: (ArtistSummaryEntity) -> Boolean): Int = + summaries.asSequence() + .filterNot { it.artist.equals("Unknown Artist", ignoreCase = true) } + .filter(keep) + .map { normalizeArtistKey(it.artist) } + .filter { it.isNotBlank() } + .distinct() + .count() + + val pending = named(byMode.values.flatten()) { summary -> + isArtistImagePending( + images[artistImageMapKey(summary.groupingMode, summary.artistKey)], + now, + ) + } + val missing = named(byMode[mode].orEmpty()) { summary -> + val image = images[artistImageMapKey(summary.groupingMode, summary.artistKey)] + image?.manualImageHash == null && image?.automaticImageHash == null + } + mapOf("pending" to pending, "missing" to missing) + } + + suspend fun getPendingArtistImageLookups( + requestedLimit: Int, + now: Long, + ): List> = withCatalogRecovery { database -> + val revision = database.catalogDao().getRevision() + val summaries = listOf("astra", "fileTags").flatMap { mode -> + database.catalogDao().getAllArtistSummaries(revision, mode) + } + if (summaries.isEmpty()) return@withCatalogRecovery emptyList() + + // Initial backfills can contain tens of thousands of artists; avoid an + // SQLite IN clause large enough to exceed the device's bind-variable cap. + val images = requireUser().userDao().getAllArtistImages() + .associateBy { artistImageMapKey(it.groupingMode, it.artistKey) } + val pending = summaries.asSequence() + .filterNot { it.artist.equals("Unknown Artist", ignoreCase = true) } + .filter { summary -> + isArtistImagePending( + images[artistImageMapKey(summary.groupingMode, summary.artistKey)], + now, + ) + } + .sortedWith( + compareBy( + ArtistSummaryEntity::nameSortKey, + ArtistSummaryEntity::artist, + ArtistSummaryEntity::groupingMode, + ArtistSummaryEntity::artistKey, + ), + ) + .toList() + val limit = requestedLimit.coerceIn(1, 500) + val boundaryArtist = pending.getOrNull(limit - 1)?.artist?.let(::normalizeArtistKey) + (pending.take(limit) + pending.drop(limit).takeWhile { summary -> + normalizeArtistKey(summary.artist) == boundaryArtist + }).asSequence() + .map { summary -> + mapOf( + "groupingMode" to summary.groupingMode, + "artistKey" to summary.artistKey, + "artistName" to summary.artist, + "retryCount" to ( + images[artistImageMapKey(summary.groupingMode, summary.artistKey)]?.retryCount ?: 0 + ), + ) + } + .toList() + } + + suspend fun getArtistImageState( + artistKey: String, + groupingMode: String, + ): Map { + initialize() + val mode = normalizeGroupingMode(groupingMode) + val key = normalizeArtistKey(artistKey) + val image = requireUser().userDao().getArtistImage(mode, key) + return image?.toBridgeMap() ?: mapOf( + "groupingMode" to mode, + "artistKey" to key, + "manualImageHash" to null, + "automaticImageHash" to null, + "automaticProvider" to null, + "automaticSourceId" to null, + "lookupStatus" to "never", + "retryCount" to 0, + "lastAttemptAt" to null, + "nextRetryAt" to null, + "updatedAt" to null, + ) + } + + /** + * Makes previously unmatched artists eligible for lookup again, returning how + * many were re-queued. `not_found` is otherwise terminal — without this a + * provider outage, or a bug in the provider client, permanently poisons every + * artist it touched. Scans call this so "rescan" also means "re-check the + * artists that came back empty". + */ + suspend fun clearArtistImageLookupFailures(): Int { + initialize() + val cleared = requireUser().userDao().deleteFailedArtistImageLookups() + if (cleared > 0) scheduleSnapshot() + return cleared + } + + suspend fun recordArtistImageLookup( + artistKey: String, + artistName: String, + groupingMode: String, + status: String, + automaticImageHash: String?, + provider: String?, + sourceId: String?, + attemptedAt: Long, + nextRetryAt: Long?, + clearManual: Boolean, + ) { + initialize() + val mode = normalizeGroupingMode(groupingMode) + val key = normalizeArtistKey(artistKey) + val safeStatus = when (status) { + "found", "not_found", "transient_error" -> status + else -> error("Invalid artist image lookup status") + } + if (safeStatus == "found" && automaticImageHash.isNullOrBlank()) { + error("A found artist image requires a cached artwork hash") + } + val dao = requireUser().userDao() + val existing = dao.getArtistImage(mode, key) + dao.putArtistImage( + ArtistImageEntity( + groupingMode = mode, + artistKey = key, + artistName = artistName.trim().ifEmpty { existing?.artistName ?: artistKey }, + manualImageHash = if (clearManual) null else existing?.manualImageHash, + automaticImageHash = when (safeStatus) { + "found" -> automaticImageHash + "not_found" -> null + else -> existing?.automaticImageHash + }, + automaticProvider = when (safeStatus) { + "found" -> provider + "not_found" -> null + else -> existing?.automaticProvider + }, + automaticSourceId = when (safeStatus) { + "found" -> sourceId + "not_found" -> null + else -> existing?.automaticSourceId + }, + lookupStatus = safeStatus, + retryCount = if (safeStatus == "transient_error") { + (existing?.retryCount ?: 0) + 1 + } else { + 0 + }, + lastAttemptAt = attemptedAt, + nextRetryAt = if (safeStatus == "transient_error") nextRetryAt else null, + updatedAt = System.currentTimeMillis(), + ), + ) + scheduleSnapshot() + } + + suspend fun setManualArtistImage( + artistKey: String, + artistName: String, + groupingMode: String, + artworkHash: String, + ) { + initialize() + require(artworkHash.isNotBlank()) { "A cached artwork hash is required" } + val mode = normalizeGroupingMode(groupingMode) + val key = normalizeArtistKey(artistKey) + val dao = requireUser().userDao() + val existing = dao.getArtistImage(mode, key) + dao.putArtistImage( + (existing ?: ArtistImageEntity( + groupingMode = mode, + artistKey = key, + artistName = artistName.trim().ifEmpty { artistKey }, + updatedAt = System.currentTimeMillis(), + )).copy( + artistName = artistName.trim().ifEmpty { existing?.artistName ?: artistKey }, + manualImageHash = artworkHash, + updatedAt = System.currentTimeMillis(), + ), + ) + scheduleSnapshot() + } + + suspend fun clearManualArtistImage( + artistKey: String, + artistName: String, + groupingMode: String, + ) { + initialize() + val mode = normalizeGroupingMode(groupingMode) + val key = normalizeArtistKey(artistKey) + val dao = requireUser().userDao() + val existing = dao.getArtistImage(mode, key) ?: return + dao.putArtistImage( + existing.copy( + artistName = artistName.trim().ifEmpty { existing.artistName }, + manualImageHash = null, + updatedAt = System.currentTimeMillis(), + ), + ) + scheduleSnapshot() + } + + private suspend fun bridgeArtistSummary(row: ArtistSummaryEntity?): Map? = + row?.let { summary -> + val image = requireUser().userDao().getArtistImage(summary.groupingMode, summary.artistKey) + summary.toBridgeMap(image) + } + + private suspend fun bridgeArtistSummaries( + rows: List, + ): List> { + if (rows.isEmpty()) return emptyList() + val images = loadArtistImages(rows) + return rows.map { summary -> + summary.toBridgeMap(images[artistImageMapKey(summary.groupingMode, summary.artistKey)]) + } + } + + private suspend fun loadArtistImages( + rows: List, + ): Map { + val dao = requireUser().userDao() + return rows + .groupBy(ArtistSummaryEntity::groupingMode) + .flatMap { (mode, summaries) -> + dao.getArtistImages(mode, summaries.map(ArtistSummaryEntity::artistKey).distinct()) + } + .associateBy { artistImageMapKey(it.groupingMode, it.artistKey) } + } + + private fun ArtistImageEntity.toBridgeMap(): Map = mapOf( + "groupingMode" to groupingMode, + "artistKey" to artistKey, + "artistName" to artistName, + "manualImageHash" to manualImageHash, + "automaticImageHash" to automaticImageHash, + "automaticProvider" to automaticProvider, + "automaticSourceId" to automaticSourceId, + "lookupStatus" to lookupStatus, + "retryCount" to retryCount, + "lastAttemptAt" to lastAttemptAt?.toDouble(), + "nextRetryAt" to nextRetryAt?.toDouble(), + "updatedAt" to updatedAt.toDouble(), + ) + + private fun artistImageMapKey(groupingMode: String, artistKey: String): String = + "$groupingMode\u0000$artistKey" + + private fun normalizeGroupingMode(value: String): String = + if (value == "fileTags") "fileTags" else "astra" + suspend fun matchSignal( title: String, artist: String, @@ -2919,7 +3223,7 @@ class AstraLibraryRepository private constructor( private fun buildUserDatabase(): AstraUserDatabase = Room.databaseBuilder(applicationContext, AstraUserDatabase::class.java, USER_DB_NAME) .setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING) - .addMigrations(USER_MIGRATION_1_2, USER_MIGRATION_2_3) + .addMigrations(USER_MIGRATION_1_2, USER_MIGRATION_2_3, USER_MIGRATION_3_4) .build() private fun buildCatalogDatabase(): AstraCatalogDatabase = 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 38426a4..59e7f40 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 @@ -188,21 +188,39 @@ fun AlbumSummaryEntity.toBridgeMap(): Map = mapOf( "latest_added_at" to latestAddedAt.toDouble(), ) -fun ArtistSummaryEntity.toBridgeMap(): Map = mapOf( - "artist" to artist, - "track_count" to trackCount.toDouble(), - "primary_track_count" to primaryTrackCount.toDouble(), - "album_count" to albumCount.toDouble(), - "artwork_hash" to artworkHash, - "source_type" to sourceType, - "source_id" to sourceId?.toDouble(), - "artwork_source_id" to artworkSourceId, - "is_collaboration" to isCollaboration, - "artwork_hashes" to runCatching { - val array = JSONArray(artworkHashesJson) - List(array.length()) { index -> array.getString(index) } - }.getOrDefault(emptyList()), -) +fun ArtistSummaryEntity.toBridgeMap(): Map = toBridgeMap(null) + +fun ArtistSummaryEntity.toBridgeMap(image: ArtistImageEntity?): Map { + val portraitHash = image?.manualImageHash ?: image?.automaticImageHash + val resolvedHash = portraitHash ?: artworkHash + val resolvedHashes = if (portraitHash != null) { + listOf(portraitHash) + } else { + runCatching { + val array = JSONArray(artworkHashesJson) + List(array.length()) { index -> array.getString(index) } + }.getOrDefault(emptyList()) + } + val artworkSource = when { + image?.manualImageHash != null -> "manual" + image?.automaticImageHash != null -> "deezer" + artworkHash != null -> "track" + else -> null + } + return mapOf( + "artist" to artist, + "track_count" to trackCount.toDouble(), + "primary_track_count" to primaryTrackCount.toDouble(), + "album_count" to albumCount.toDouble(), + "artwork_hash" to resolvedHash, + "source_type" to sourceType, + "source_id" to sourceId?.toDouble(), + "artwork_source_id" to artworkSourceId, + "is_collaboration" to isCollaboration, + "artwork_hashes" to resolvedHashes, + "artwork_source" to artworkSource, + ) +} fun RemoteSourceEntity.toBridgeMap(): Map = mapOf( "id" to id.toDouble(), diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/UserDatabase.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/UserDatabase.kt index be1942f..74208c7 100644 --- a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/UserDatabase.kt +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/UserDatabase.kt @@ -39,6 +39,31 @@ interface UserDao { @Query("SELECT * FROM settings ORDER BY key") suspend fun snapshotSettings(): List + @Query("SELECT * FROM artist_images ORDER BY grouping_mode, artist_key") + suspend fun getAllArtistImages(): List + + @Query("SELECT * FROM artist_images WHERE grouping_mode = :groupingMode AND artist_key IN (:artistKeys)") + suspend fun getArtistImages(groupingMode: String, artistKeys: List): List + + @Query("SELECT * FROM artist_images WHERE grouping_mode = :groupingMode AND artist_key = :artistKey LIMIT 1") + suspend fun getArtistImage(groupingMode: String, artistKey: String): ArtistImageEntity? + + /** + * Drops rows that record nothing but "the provider had no match", so those + * artists are pending again. Rows holding a manual image are kept: they + * already have a portrait and never need an automatic one. + */ + @Query( + "DELETE FROM artist_images WHERE lookup_status = 'not_found' AND manual_image_hash IS NULL", + ) + suspend fun deleteFailedArtistImageLookups(): Int + + @Upsert + suspend fun putArtistImage(image: ArtistImageEntity) + + @Upsert + suspend fun putArtistImages(images: List) + @Query("SELECT * FROM folders ORDER BY added_at, id") suspend fun getFolders(): List @@ -779,8 +804,9 @@ interface UserDao { PlaybackQueueEntryEntity::class, PlaybackOriginalQueueEntryEntity::class, SnapshotMetadataEntity::class, + ArtistImageEntity::class, ], - version = 3, + version = 4, exportSchema = true, ) abstract class AstraUserDatabase : RoomDatabase() { @@ -1011,3 +1037,31 @@ internal val USER_MIGRATION_2_3 = object : Migration(2, 3) { ) } } + +internal val USER_MIGRATION_3_4 = object : Migration(3, 4) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `artist_images` ( + `grouping_mode` TEXT NOT NULL, + `artist_key` TEXT NOT NULL, + `artist_name` TEXT NOT NULL, + `manual_image_hash` TEXT, + `automatic_image_hash` TEXT, + `automatic_provider` TEXT, + `automatic_source_id` TEXT, + `lookup_status` TEXT NOT NULL, + `retry_count` INTEGER NOT NULL, + `last_attempt_at` INTEGER, + `next_retry_at` INTEGER, + `updated_at` INTEGER NOT NULL, + PRIMARY KEY(`grouping_mode`, `artist_key`) + ) + """.trimIndent(), + ) + database.execSQL( + "CREATE INDEX IF NOT EXISTS `index_artist_images_lookup_status_next_retry_at` " + + "ON `artist_images` (`lookup_status`, `next_retry_at`)", + ) + } +} diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/UserEntities.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/UserEntities.kt index 8f41f39..c9a91d5 100644 --- a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/UserEntities.kt +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/UserEntities.kt @@ -283,3 +283,23 @@ data class SnapshotMetadataEntity( @PrimaryKey val id: Int = 1, @ColumnInfo(name = "last_snapshot_at") val lastSnapshotAt: Long, ) + +@Entity( + tableName = "artist_images", + primaryKeys = ["grouping_mode", "artist_key"], + indices = [Index(value = ["lookup_status", "next_retry_at"])], +) +data class ArtistImageEntity( + @ColumnInfo(name = "grouping_mode") val groupingMode: String, + @ColumnInfo(name = "artist_key") val artistKey: String, + @ColumnInfo(name = "artist_name") val artistName: String, + @ColumnInfo(name = "manual_image_hash") val manualImageHash: String? = null, + @ColumnInfo(name = "automatic_image_hash") val automaticImageHash: String? = null, + @ColumnInfo(name = "automatic_provider") val automaticProvider: String? = null, + @ColumnInfo(name = "automatic_source_id") val automaticSourceId: String? = null, + @ColumnInfo(name = "lookup_status") val lookupStatus: String = "never", + @ColumnInfo(name = "retry_count") val retryCount: Int = 0, + @ColumnInfo(name = "last_attempt_at") val lastAttemptAt: Long? = null, + @ColumnInfo(name = "next_retry_at") val nextRetryAt: Long? = null, + @ColumnInfo(name = "updated_at") val updatedAt: Long, +) diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/UserSnapshotStore.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/UserSnapshotStore.kt index b85d0cd..07804f5 100644 --- a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/UserSnapshotStore.kt +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/UserSnapshotStore.kt @@ -39,6 +39,7 @@ class UserSnapshotStore( json.put("pendingFavorites", dao.getPendingFavorites().toJsonArray { it.toJson() }) json.put("playlistTombstones", dao.getPlaylistTombstones().toJsonArray { it.toJson() }) json.put("playlistSyncStates", dao.getPlaylistSyncStates().toJsonArray { it.toJson() }) + json.put("artistImages", dao.getAllArtistImages().toJsonArray { it.toJson() }) val sessions = dao.getPlaybackSessions() json.put("playbackSessions", sessions.toJsonArray { it.toJson() }) json.put( @@ -102,6 +103,7 @@ class UserSnapshotStore( dao.putPendingFavorites(payload.array("pendingFavorites").mapObjects(::pendingFavoriteFromJson)) dao.putPlaylistTombstones(payload.array("playlistTombstones").mapObjects(::playlistTombstoneFromJson)) dao.putPlaylistSyncStates(payload.array("playlistSyncStates").mapObjects(::playlistSyncStateFromJson)) + dao.putArtistImages(payload.array("artistImages").mapObjects(::artistImageFromJson)) val sessions = payload.array("playbackSessions").mapObjects(::playbackSessionFromJson) if (sessions.isNotEmpty()) { sessions.forEach { dao.putPlaybackSession(it) } @@ -328,6 +330,35 @@ private fun playlistSyncStateFromJson(json: JSONObject) = PlaylistSyncStateEntit remoteUpdatedAt = json.getLong("remoteUpdatedAt"), ) +private fun ArtistImageEntity.toJson() = JSONObject() + .put("groupingMode", groupingMode) + .put("artistKey", artistKey) + .put("artistName", artistName) + .putNullable("manualImageHash", manualImageHash) + .putNullable("automaticImageHash", automaticImageHash) + .putNullable("automaticProvider", automaticProvider) + .putNullable("automaticSourceId", automaticSourceId) + .put("lookupStatus", lookupStatus) + .put("retryCount", retryCount) + .putNullable("lastAttemptAt", lastAttemptAt) + .putNullable("nextRetryAt", nextRetryAt) + .put("updatedAt", updatedAt) + +private fun artistImageFromJson(json: JSONObject) = ArtistImageEntity( + groupingMode = json.getString("groupingMode"), + artistKey = json.getString("artistKey"), + artistName = json.getString("artistName"), + manualImageHash = json.nullableString("manualImageHash"), + automaticImageHash = json.nullableString("automaticImageHash"), + automaticProvider = json.nullableString("automaticProvider"), + automaticSourceId = json.nullableString("automaticSourceId"), + lookupStatus = json.optString("lookupStatus", "never"), + retryCount = json.optInt("retryCount", 0), + lastAttemptAt = json.nullableLong("lastAttemptAt"), + nextRetryAt = json.nullableLong("nextRetryAt"), + updatedAt = json.optLong("updatedAt", 0), +) + private fun PlaybackSessionEntity.toJson() = JSONObject() .put("id", id) .put("contextJson", contextJson) diff --git a/modules/astra-library-scanner/index.ts b/modules/astra-library-scanner/index.ts index 9ee0c8a..dc9e28f 100644 --- a/modules/astra-library-scanner/index.ts +++ b/modules/astra-library-scanner/index.ts @@ -181,6 +181,13 @@ declare class AstraLibraryScannerModuleType extends NativeModule; + /** Validate and content-addressably cache a JPEG, PNG, or WebP URI plus thumbnail. */ + cacheArtworkFromUri(uri: string): Promise; + /** + * A wait that still elapses while the app is backgrounded, unlike `setTimeout` + * — React Native stops firing JS timers once the activity pauses. + */ + backgroundDelay(milliseconds: number): Promise; getPersistedTreeUris(): string[]; takePersistableUriPermission(uri: string): Promise; releasePersistedUriPermission(uri: string): Promise; @@ -296,6 +303,28 @@ export interface NativeLibraryLoudnessStats { medianRgTrackDb: number | null; } +export interface NativeArtistImageLookupTarget { + groupingMode: 'astra' | 'fileTags'; + artistKey: string; + artistName: string; + retryCount: number; +} + +export interface NativeArtistImageState { + groupingMode: 'astra' | 'fileTags'; + artistKey: string; + artistName?: string; + manualImageHash: string | null; + automaticImageHash: string | null; + automaticProvider: 'deezer' | null; + automaticSourceId: string | null; + lookupStatus: 'never' | 'found' | 'not_found' | 'transient_error'; + retryCount: number; + lastAttemptAt: number | null; + nextRetryAt: number | null; + updatedAt: number | null; +} + type AstraLibraryDataEvents = { onLibraryStatus: (event: LibraryStatusSnapshot) => void; onScanProgress: (event: { @@ -306,6 +335,10 @@ type AstraLibraryDataEvents = { folderName: string; }) => void; onCatalogChanged: (event: { catalogRevision: string }) => void; + onArtistImagesChanged: (event: { + artistKey: string; + groupingMode: 'astra' | 'fileTags'; + }) => void; }; declare class AstraLibraryDataModuleType extends NativeModule { @@ -505,6 +538,53 @@ declare class AstraLibraryDataModuleType extends NativeModule; + getPendingArtistImageLookups( + limit: number, + now: number + ): Promise; + /** + * Re-queues artists a provider previously had no match for, returning how many + * became pending. `not_found` is otherwise terminal. + */ + clearArtistImageLookupFailures(): Promise; + /** + * `pending` = distinct artists awaiting a lookup across both grouping modes + * (the denominator for sweep progress). `missing` = artists in `groupingMode` + * with no portrait at all, including ones already written off as not_found. + */ + getArtistImageStats( + groupingMode: 'astra' | 'fileTags', + now: number + ): Promise<{ pending: number; missing: number }>; + getArtistImageState( + artistKey: string, + groupingMode: 'astra' | 'fileTags' + ): Promise; + recordArtistImageLookup( + artistKey: string, + artistName: string, + groupingMode: 'astra' | 'fileTags', + values: { + status: 'found' | 'not_found' | 'transient_error'; + automaticImageHash?: string | null; + provider?: 'deezer' | null; + sourceId?: string | null; + attemptedAt: number; + nextRetryAt?: number | null; + clearManual?: boolean; + } + ): Promise; + setManualArtistImage( + artistKey: string, + artistName: string, + groupingMode: 'astra' | 'fileTags', + artworkHash: string + ): Promise; + clearManualArtistImage( + artistKey: string, + artistName: string, + groupingMode: 'astra' | 'fileTags' + ): Promise; searchTracks(query: string, limit: number): Promise; searchLibrary( query: string, diff --git a/package-lock.json b/package-lock.json index 00cbe7a..f515d7a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "expo-haptics": "~56.0.3", "expo-image": "~56.0.9", "expo-linking": "~56.0.11", + "expo-network": "~56.0.5", "expo-router": "~56.2.6", "expo-secure-store": "^56.0.4", "expo-sharing": "~56.0.21", @@ -6434,6 +6435,16 @@ "react-native": "*" } }, + "node_modules/expo-network": { + "version": "56.0.5", + "resolved": "https://registry.npmjs.org/expo-network/-/expo-network-56.0.5.tgz", + "integrity": "sha512-zmuyO95jayDY9jyUfOAlNp9XXJrJaAOkBXXLy0TS/nh2kppj7CHirRPkQ/tf0rsxhIL3AEd9nsRTiPtNsGT9Lw==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*" + } + }, "node_modules/expo-router": { "version": "56.2.6", "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-56.2.6.tgz", diff --git a/package.json b/package.json index d7c6e27..0958548 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "expo-haptics": "~56.0.3", "expo-image": "~56.0.9", "expo-linking": "~56.0.11", + "expo-network": "~56.0.5", "expo-router": "~56.2.6", "expo-secure-store": "^56.0.4", "expo-sharing": "~56.0.21", @@ -69,6 +70,7 @@ "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 src/audio/artistCreditTransport.test.mts", + "test:artist-images": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/services/artistImages/deezer.test.mts src/library/artistImagePolicy.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/app/(tabs)/library/artist/[name].tsx b/src/app/(tabs)/library/artist/[name].tsx index 39909c3..36665fb 100644 --- a/src/app/(tabs)/library/artist/[name].tsx +++ b/src/app/(tabs)/library/artist/[name].tsx @@ -10,6 +10,7 @@ import { View } from 'react-native'; import { Image } from 'expo-image'; +import * as DocumentPicker from 'expo-document-picker'; import { Ionicons } from '@expo/vector-icons'; import { FlashList } from '@shopify/flash-list'; import { useLocalSearchParams, useRouter } from 'expo-router'; @@ -19,6 +20,9 @@ import { Text } from '@/components/Text'; import { AstraLogo } from '@/components/AstraLogo'; import { TrackRow } from '@/components/library/TrackRow'; import { TrackActionsSheet } from '@/components/library/TrackActionsSheet'; +import { ArtistImageSearchSheet } from '@/components/library/ArtistImageSearchSheet'; +import { ActionSheet, type ActionSheetItem } from '@/components/sheets/ActionSheet'; +import { showAppDialog } from '@/components/dialogs/AppDialog'; import { CollapsingHeader, useDetailCollapse } from '@/components/library/CollapsingDetail'; import { fontSize, @@ -43,6 +47,13 @@ import { } from '@/library/nativePages'; import { useLibraryDetailBack } from '@/navigation/useLibraryDetailBack'; import type { DbTrack } from '@/types/library'; +import type { DeezerArtistCandidate } from '@/types/artistImages'; +import { normalizeKey } from '@/shared/library/albumGrouping'; +import { + resetLocalArtistImage, + selectDeezerArtistImage, + selectLocalArtistImage, +} from '@/library/artistImageLookup'; type IconName = ComponentProps['name']; type ArtistSectionTarget = 'songs' | 'albums' | 'appearances'; @@ -79,6 +90,9 @@ export default function ArtistScreen() { const detailGroupingMode = credit === '1' ? 'astra' : groupingMode; const currentPath = usePlayerStore((s) => s.currentTrack?.path); const [actionTrack, setActionTrack] = useState(null); + const [imageMenuOpen, setImageMenuOpen] = useState(false); + const [imageSearchOpen, setImageSearchOpen] = useState(false); + const [imageBusy, setImageBusy] = useState(false); const allPage = useNativeArtistDetail(name, detailGroupingMode, 'all'); const songsPage = useNativeArtistDetail(name, detailGroupingMode, 'songs'); @@ -211,6 +225,78 @@ export default function ArtistScreen() { const backdropHash = detail.artworkHashes[0] ?? null; const disabled = detail.playbackTracks.length === 0; + const artistKey = normalizeKey(name); + + const reportImageError = (message: string) => { + showAppDialog({ + title: 'Artist image unchanged', + message, + }); + }; + + const chooseLocalImage = async () => { + setImageMenuOpen(false); + try { + const result = await DocumentPicker.getDocumentAsync({ + type: ['image/jpeg', 'image/png', 'image/webp'], + copyToCacheDirectory: true, + }); + if (result.canceled || !result.assets[0]) return; + setImageBusy(true); + await selectLocalArtistImage( + artistKey, + name, + detailGroupingMode, + result.assets[0].uri + ); + } catch { + reportImageError('Choose a valid JPEG, PNG, or WebP image smaller than 12 MB.'); + } finally { + setImageBusy(false); + } + }; + + const chooseDeezerImage = async (candidate: DeezerArtistCandidate) => { + await selectDeezerArtistImage(artistKey, name, detailGroupingMode, candidate); + }; + + const resetImage = async () => { + setImageMenuOpen(false); + setImageBusy(true); + try { + await resetLocalArtistImage(artistKey, name, detailGroupingMode); + } catch { + reportImageError('Astra could not reset this artist image. Try again.'); + } finally { + setImageBusy(false); + } + }; + + const imageActions: ActionSheetItem[] = [ + { + key: 'search-deezer', + label: 'Search Deezer', + icon: 'search-outline', + onPress: () => { + setImageMenuOpen(false); + setImageSearchOpen(true); + }, + }, + { + key: 'choose-local', + label: imageBusy ? 'Choosing local image…' : 'Choose local image', + icon: 'image-outline', + onPress: () => void chooseLocalImage(), + }, + ...(allPage.summary?.artwork_source === 'manual' + ? [{ + key: 'reset', + label: 'Reset to automatic', + icon: 'refresh-outline' as const, + onPress: () => void resetImage(), + }] + : []), + ]; return ( @@ -260,6 +346,8 @@ export default function ArtistScreen() { backLabel={backLabel} onPlay={playArtist} onShuffle={shuffleArtist} + onMore={() => setImageMenuOpen(true)} + moreAccessibilityLabel="Artist image options" scrollY={scrollY} heroFaded={heroFaded} collapsed={collapsed} @@ -267,6 +355,19 @@ export default function ArtistScreen() { onHeroBlockLayout={onHeroBlockLayout} /> setActionTrack(null)} /> + setImageMenuOpen(false)} + /> + {imageSearchOpen ? ( + setImageSearchOpen(false)} + onSelect={chooseDeezerImage} + /> + ) : null} ); } diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index 49f7c94..f768b05 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -44,13 +44,16 @@ import { useDesktopSyncStore } from '@/stores/desktopSyncStore'; import { SyncConflictPrompt } from '@/components/sync/SyncConflictPrompt'; import { useThemeStore } from '@/stores/themeStore'; import { useOnboardingStore } from '@/stores/onboardingStore'; +import { useSettingsStore } from '@/stores/settingsStore'; import { OnboardingFlow } from '@/components/onboarding/OnboardingFlow'; +import { ArtistImageDisclosurePrompt } from '@/components/onboarding/ArtistImageDisclosurePrompt'; import { useTheme } from '@/theme/themed'; import { SessionLifecycle } from '@/session/SessionLifecycle'; import { useLyricsSettingsStore } from '@/stores/lyricsSettingsStore'; import { useSleepTimerStore } from '@/stores/sleepTimerStore'; import { Text } from '@/components/Text'; import { AppDialogHost } from '@/components/dialogs/AppDialog'; +import { startArtistImageLookupCoordinator } from '@/library/artistImageLookup'; // Anchor the root stack at the tabs so a deep link straight to a top-level route // (the widget's `recently-played`, the notification-click redirect) builds @@ -349,6 +352,7 @@ export default function RootLayout() { // Library tab + playback adapters get data immediately. EQ + audio settings load // alongside so the native EQ/gain reflect persisted prefs from the first play. useEffect(() => { + startArtistImageLookupCoordinator(); useThemeStore .getState() .load() @@ -448,6 +452,7 @@ export default function RootLayout() { + ) : ( // First-run gate: opaque full-screen wizard over the (hidden) navigator. @@ -455,7 +460,10 @@ export default function RootLayout() { { - void useOnboardingStore.getState().markComplete(); + void (async () => { + await useSettingsStore.getState().acknowledgeArtistImageDisclosure(); + await useOnboardingStore.getState().markComplete(); + })(); }} /> diff --git a/src/components/library/ArtistImageSearchSheet.tsx b/src/components/library/ArtistImageSearchSheet.tsx new file mode 100644 index 0000000..2107fb1 --- /dev/null +++ b/src/components/library/ArtistImageSearchSheet.tsx @@ -0,0 +1,265 @@ +import { useEffect, useState } from 'react'; +import { + ActivityIndicator, + Linking, + Pressable, + StyleSheet, + View, +} from 'react-native'; +import { Image } from 'expo-image'; +import { BottomSheetTextInput } from '@gorhom/bottom-sheet'; +import { Ionicons } from '@expo/vector-icons'; +import { AppSheet, AppSheetTitle } from '@/components/sheets/AppSheet'; +import { Text } from '@/components/Text'; +import { radius, spacing } from '@/theme'; +import { createThemedStyles, useColors } from '@/theme/themed'; +import { useRipple } from '@/theme/ripple'; +import { searchArtistImageCandidates } from '@/library/artistImageLookup'; +import type { DeezerArtistCandidate } from '@/types/artistImages'; + +export function ArtistImageSearchSheet({ + artistName, + onClose, + onSelect, +}: { + artistName: string; + onClose: () => void; + onSelect: (candidate: DeezerArtistCandidate) => Promise; +}) { + const styles = useStyles(); + const colors = useColors(); + const ripple = useRipple(); + const [query, setQuery] = useState(artistName); + const [candidates, setCandidates] = useState([]); + const [loading, setLoading] = useState(false); + const [selectedId, setSelectedId] = useState(null); + const [error, setError] = useState(null); + + const search = async (value = query) => { + const trimmed = value.trim(); + if (!trimmed || loading) return; + setLoading(true); + setError(null); + try { + const result = await searchArtistImageCandidates(trimmed); + if (result.status === 'transient_error') { + setCandidates([]); + setError(result.message); + } else { + setCandidates(result.candidates); + if (result.candidates.length === 0) { + setError('No artist images matched that search.'); + } + } + } finally { + setLoading(false); + } + }; + + useEffect(() => { + queueMicrotask(() => void search(artistName)); + // Run once with the artist name used to open this sheet. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [artistName]); + + const choose = async (candidate: DeezerArtistCandidate) => { + if (selectedId) return; + setSelectedId(candidate.id); + setError(null); + try { + await onSelect(candidate); + onClose(); + } catch { + setError('That image could not be downloaded. Check your connection and try again.'); + setSelectedId(null); + } + }; + + const openDeezerLink = async (url: string) => { + try { + await Linking.openURL(url); + } catch { + setError('Astra could not open the Deezer link.'); + } + }; + + return ( + + + + void search()} + placeholder="Artist name" + placeholderTextColor={colors.textTertiary} + returnKeyType="search" + autoCapitalize="words" + style={styles.input} + accessibilityLabel="Deezer artist search" + /> + void search()} + accessibilityRole="button" + accessibilityLabel="Search Deezer" + > + {loading ? ( + + ) : ( + + )} + + + + {error ? ( + + + + {error} + + + ) : null} + + + {candidates.map((candidate) => ( + void choose(candidate)} + accessibilityRole="button" + accessibilityLabel={`Use Deezer image for ${candidate.name}`} + > + + + {candidate.name} + + {formatFans(candidate.fanCount)} + + + {candidate.linkUrl ? ( + { + event.stopPropagation(); + void openDeezerLink(candidate.linkUrl!); + }} + accessibilityRole="link" + accessibilityLabel={`Open ${candidate.name} on Deezer`} + > + + + ) : null} + {selectedId === candidate.id ? ( + + ) : ( + + )} + + ))} + + + void openDeezerLink('https://www.deezer.com/')} + accessibilityRole="link" + > + Images and artist data from Deezer + + + + ); +} + +function formatFans(count: number): string { + return `${new Intl.NumberFormat().format(count)} Deezer fans`; +} + +const useStyles = createThemedStyles((colors) => ({ + searchRow: { + flexDirection: 'row', + gap: spacing.sm, + alignItems: 'center', + }, + input: { + flex: 1, + height: 48, + borderRadius: radius.md, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + backgroundColor: colors.bgTertiary, + color: colors.textPrimary, + paddingHorizontal: spacing.md, + fontFamily: 'Inter_400Regular', + fontSize: 16, + }, + searchButton: { + width: 48, + height: 48, + borderRadius: radius.md, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.accentGlow, + overflow: 'hidden', + }, + message: { + flexDirection: 'row', + gap: spacing.sm, + marginTop: spacing.md, + padding: spacing.md, + borderRadius: radius.md, + backgroundColor: colors.bgTertiary, + }, + messageText: { + flex: 1, + }, + results: { + marginTop: spacing.sm, + }, + candidate: { + minHeight: 76, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + paddingVertical: spacing.sm, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.glassBorder, + }, + thumbnail: { + width: 58, + height: 58, + borderRadius: radius.pill, + backgroundColor: colors.bgTertiary, + }, + candidateText: { + flex: 1, + minWidth: 0, + gap: 3, + }, + providerLink: { + width: 36, + height: 36, + alignItems: 'center', + justifyContent: 'center', + borderRadius: radius.pill, + }, + attribution: { + minHeight: 48, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.xs, + marginTop: spacing.md, + }, +})); diff --git a/src/components/library/ArtistImageSweepStatus.tsx b/src/components/library/ArtistImageSweepStatus.tsx new file mode 100644 index 0000000..b683286 --- /dev/null +++ b/src/components/library/ArtistImageSweepStatus.tsx @@ -0,0 +1,131 @@ +import { useEffect, useState } from 'react'; +import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { Text } from '@/components/Text'; +import { radius, spacing } from '@/theme'; +import { createThemedStyles, useColors } from '@/theme/themed'; +import { useRipple } from '@/theme/ripple'; +import { useArtistImageStore } from '@/stores/artistImageStore'; +import { requeueMissingArtistImages } from '@/library/artistImageLookup'; + +const n = (value: number) => value.toLocaleString(); + +/** + * Live sweep progress, or — when idle — how many artists still have no portrait + * plus a way to look again. The retry exists because `not_found` is terminal in + * the pending query: without it, re-checking those artists would mean rescanning + * the whole library. + */ +export function ArtistImageSweepStatus({ enabled }: { enabled: boolean }) { + const styles = useStyles(); + const colors = useColors(); + const ripple = useRipple(); + const running = useArtistImageStore((s) => s.running); + const processed = useArtistImageStore((s) => s.processed); + const total = useArtistImageStore((s) => s.total); + const missing = useArtistImageStore((s) => s.missing); + const refreshMissing = useArtistImageStore((s) => s.refreshMissing); + const [retrying, setRetrying] = useState(false); + + useEffect(() => { + void refreshMissing(); + }, [refreshMissing]); + + if (!enabled) return null; + + if (running) { + // Clamped: the denominator is counted once up front and normalizes names + // slightly differently than the grouping does, so it can drift by a few. + const fraction = total > 0 ? Math.min(processed / total, 1) : 0; + return ( + + + {total > 0 + ? `Looking up artist images… ${n(processed)} of ${n(total)}` + : 'Looking up artist images…'} + + + 0 ? { width: `${fraction * 100}%` } : styles.fillIndeterminate, + ]} + /> + + + ); + } + + if (missing <= 0) return null; + + const retry = async () => { + if (retrying) return; + setRetrying(true); + try { + await requeueMissingArtistImages(); + } finally { + setRetrying(false); + } + }; + + return ( + + + {missing === 1 ? '1 artist has no image' : `${n(missing)} artists have no image`} + + void retry()} + accessibilityRole="button" + accessibilityLabel="Look for missing artist images now" + > + {retrying ? ( + + ) : ( + + )} + + Look for missing images + + + + ); +} + +const useStyles = createThemedStyles((colors) => ({ + container: { + gap: spacing.sm, + marginTop: spacing.md, + }, + track: { + height: 2, + backgroundColor: colors.glassBorder, + borderRadius: 1, + overflow: 'hidden', + }, + fill: { + height: 2, + backgroundColor: colors.accent, + }, + fillIndeterminate: { + width: '100%', + opacity: 0.35, + }, + button: { + minHeight: 44, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.md, + borderRadius: radius.md, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + backgroundColor: colors.accentGlow, + overflow: 'hidden', + }, +})); + +export default ArtistImageSweepStatus; diff --git a/src/components/library/CollapsingDetail.tsx b/src/components/library/CollapsingDetail.tsx index 76dcbdf..6a72144 100644 --- a/src/components/library/CollapsingDetail.tsx +++ b/src/components/library/CollapsingDetail.tsx @@ -128,6 +128,7 @@ export function CollapsingHeader({ onBack, backLabel, onMore, + moreAccessibilityLabel = 'More options', onPlay, onShuffle, scrollY, @@ -149,6 +150,7 @@ export function CollapsingHeader({ /** Names the screen `onBack` returns to; must track the real action. */ backLabel: string; onMore?: () => void; + moreAccessibilityLabel?: string; onPlay: () => void; onShuffle: () => void; scrollY: SharedValue; @@ -327,7 +329,7 @@ export function CollapsingHeader({ hitSlop={8} style={[styles.moreButton, { top: barCenterY - 16, right: spacing.md }]} accessibilityRole="button" - accessibilityLabel="Playlist options" + accessibilityLabel={moreAccessibilityLabel} > diff --git a/src/components/library/ScanNotificationPermissionCard.tsx b/src/components/library/ScanNotificationPermissionCard.tsx new file mode 100644 index 0000000..5ef3cd3 --- /dev/null +++ b/src/components/library/ScanNotificationPermissionCard.tsx @@ -0,0 +1,123 @@ +import { + ActivityIndicator, + Pressable, + StyleSheet, + View, + type StyleProp, + type ViewStyle, +} from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { Text } from '@/components/Text'; +import { radius, spacing } from '@/theme'; +import { createThemedStyles, useColors } from '@/theme/themed'; +import { useRipple } from '@/theme/ripple'; +import { useScanNotificationPermission } from '@/library/useScanNotificationPermission'; + +/** + * Dense settings-list form of the notification permission, rendered only while + * there is still something to ask for. A permanently-satisfied "Allowed" row is + * noise in a settings list, so the card removes itself once the permission is + * held (or was never required) — including the surrounding spacing, which is why + * the caller passes `style` instead of wrapping this in its own View. + * + * The onboarding wizard deliberately does not reuse this: it has a whole page to + * fill, so it renders its own layout over the same + * `useScanNotificationPermission` state. + */ +export function ScanNotificationPermissionCard({ + style, +}: { + style?: StyleProp; +}) { + const styles = useStyles(); + const colors = useColors(); + const ripple = useRipple(); + const { state, granted, denied, working, resolve } = useScanNotificationPermission(); + + // Also hidden while the first check is in flight, so the row never appears + // just to vanish a frame later on an already-granted device. + if (state === null || granted) return null; + + return ( + + + + + + + Scan progress notification + + The temporary notification shows progress and lets Android keep a scan running + after you leave Astra. Scans still work if you skip it. + + + + + + {working ? ( + + ) : ( + + )} + + {denied ? 'Open Settings' : 'Allow scan notifications'} + + + + {denied ? ( + + Notification permission is denied. You can enable it in Android settings. + + ) : null} + + ); +} + +const useStyles = createThemedStyles((colors) => ({ + card: { + gap: spacing.md, + padding: spacing.md, + borderRadius: radius.lg, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + backgroundColor: colors.bgSecondary, + }, + header: { + flexDirection: 'row', + alignItems: 'flex-start', + gap: spacing.md, + }, + icon: { + width: 36, + height: 36, + borderRadius: radius.pill, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.accentGlow, + }, + copy: { + flex: 1, + gap: 4, + }, + button: { + minHeight: 44, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.md, + borderRadius: radius.md, + backgroundColor: colors.accentGlow, + overflow: 'hidden', + }, +})); diff --git a/src/components/onboarding/ArtistImageDisclosurePrompt.tsx b/src/components/onboarding/ArtistImageDisclosurePrompt.tsx new file mode 100644 index 0000000..8288ba6 --- /dev/null +++ b/src/components/onboarding/ArtistImageDisclosurePrompt.tsx @@ -0,0 +1,128 @@ +import { useState } from 'react'; +import { ActivityIndicator, Modal, Pressable, StyleSheet, View } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { Text } from '@/components/Text'; +import { SegmentedControl } from '@/components/SegmentedControl'; +import { radius, spacing } from '@/theme'; +import { createThemedStyles, useColors } from '@/theme/themed'; +import { useRipple } from '@/theme/ripple'; +import { useSettingsStore } from '@/stores/settingsStore'; +import type { ArtistImageAutoPolicy } from '@/types/artistImages'; + +export function ArtistImageDisclosurePrompt() { + const loaded = useSettingsStore((s) => s.loaded); + const seen = useSettingsStore((s) => s.artistImageDisclosureSeen); + const policy = useSettingsStore((s) => s.artistImageAutoPolicy); + if (!loaded || seen) return null; + return ; +} + +function ArtistImageDisclosureContent({ + initialPolicy, +}: { + initialPolicy: ArtistImageAutoPolicy; +}) { + const styles = useStyles(); + const colors = useColors(); + const ripple = useRipple(); + const [policy, setPolicy] = useState(initialPolicy); + const [saving, setSaving] = useState(false); + + const continueSetup = async () => { + if (saving) return; + setSaving(true); + try { + await useSettingsStore.getState().setArtistImageAutoPolicy(policy); + await useSettingsStore.getState().acknowledgeArtistImageDisclosure(); + } finally { + setSaving(false); + } + }; + + return ( + undefined} + > + + + + + + + Set up artist images + + Astra can send artist names to Deezer, then store selected portraits locally + so they still appear offline. Existing album art remains the fallback. + + + setPolicy(value as ArtistImageAutoPolicy)} + /> + + Ethernet is included in Wi-Fi mode. You can change this later in Settings › Library, + and manual searches work while automatic downloads are off. + + void continueSetup()} + disabled={saving} + accessibilityRole="button" + > + {saving ? ( + + ) : ( + Continue + )} + + + + + ); +} + +const useStyles = createThemedStyles((colors) => ({ + backdrop: { + flex: 1, + justifyContent: 'center', + padding: spacing.xl, + backgroundColor: colors.backdrop, + }, + card: { + gap: spacing.lg, + padding: spacing.xl, + borderRadius: radius.lg, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + backgroundColor: colors.bgSecondary, + }, + icon: { + width: 52, + height: 52, + alignItems: 'center', + justifyContent: 'center', + borderRadius: radius.pill, + backgroundColor: colors.accentGlow, + }, + copy: { + gap: spacing.sm, + }, + button: { + minHeight: 50, + alignItems: 'center', + justifyContent: 'center', + borderRadius: radius.md, + backgroundColor: colors.accent, + overflow: 'hidden', + }, +})); diff --git a/src/components/onboarding/ArtistImageStep.tsx b/src/components/onboarding/ArtistImageStep.tsx new file mode 100644 index 0000000..d8e6340 --- /dev/null +++ b/src/components/onboarding/ArtistImageStep.tsx @@ -0,0 +1,322 @@ +import { useEffect, type ComponentProps, type ReactNode } from 'react'; +import { Pressable, StyleSheet, View } from 'react-native'; +import Animated, { + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; +import { Ionicons } from '@expo/vector-icons'; +import { Text } from '@/components/Text'; +import { StepHeader } from '@/components/onboarding/StepHeader'; +import { radius, spacing } from '@/theme'; +import { motion } from '@/theme/motion'; +import { createThemedStyles, useColors } from '@/theme/themed'; +import { useRipple } from '@/theme/ripple'; +import { playHaptic } from '@/lib/haptics'; +import { useSettingsStore } from '@/stores/settingsStore'; +import type { ArtistImageAutoPolicy } from '@/types/artistImages'; + +type IoniconName = ComponentProps['name']; + +const POLICY_OPTIONS: { + policy: ArtistImageAutoPolicy; + icon: IoniconName; + title: string; + description: string; +}[] = [ + { + policy: 'wifi', + icon: 'wifi-outline', + title: 'Wi-Fi or Ethernet', + description: 'Recommended — never uses mobile data.', + }, + { + policy: 'any', + icon: 'cellular-outline', + title: 'Any network', + description: 'Includes mobile data.', + }, + { + policy: 'off', + icon: 'remove-circle-outline', + title: 'Off', + description: 'Nothing is sent. Manual search still works.', + }, +]; + +/** + * Wizard page for the Deezer artist-image disclosure. Unlike the settings row + * this flattens the toggle-plus-segmented-control into three equal-weight cards: + * the network choice is the same decision as opting in, so hiding it behind a + * switch made a consent screen read as two unrelated controls. + * + * The current policy is preselected (unlike the scope-style step, which stays + * unset to avoid biasing taste feedback) — on a consent page the user needs to + * see what will actually happen if they just tap Continue. + */ +export function ArtistImageStep() { + const styles = useStyles(); + const colors = useColors(); + const policy = useSettingsStore((s) => s.artistImageAutoPolicy); + const setPolicy = useSettingsStore((s) => s.setArtistImageAutoPolicy); + + const choose = (next: ArtistImageAutoPolicy) => { + if (next === policy) return; + playHaptic('selection'); + void setPolicy(next); + }; + + return ( + + + + + + + {POLICY_OPTIONS.map((option) => ( + choose(option.policy)} + /> + ))} + + + + Only the artist name is sent. Change this anytime in Settings › Library. + + + ); +} + +/** + * Side-by-side sketch of an artist tile with a portrait vs. the album-art + * fallback. The highlight follows the choice below — picking "Off" moves it to + * the fallback tile, so the cards and the preview always agree. + */ +function PortraitPreview({ enabled }: { enabled: boolean }) { + const styles = useStyles(); + const colors = useColors(); + return ( + + + + + + + + + ); +} + +/** + * Two stacked circle layers cross-faded by opacity rather than an animated + * border/background colour — the repo's established way to move a highlight + * without handing colours to a worklet. + */ +function PreviewTile({ + active, + label, + children, +}: { + active: boolean; + label: string; + children: ReactNode; +}) { + const styles = useStyles(); + const colors = useColors(); + const progress = useSharedValue(active ? 1 : 0); + useEffect(() => { + progress.value = withTiming(active ? 1 : 0, motion.snap); + }, [active, progress]); + const tileStyle = useAnimatedStyle(() => ({ opacity: 0.42 + progress.value * 0.58 })); + const accentStyle = useAnimatedStyle(() => ({ opacity: progress.value })); + + return ( + + + + + {children} + + + + {label} + + + ); +} + +function PolicyCard({ + icon, + title, + description, + selected, + onPress, +}: { + icon: IoniconName; + title: string; + description: string; + selected: boolean; + onPress: () => void; +}) { + const styles = useStyles(); + const colors = useColors(); + const ripple = useRipple(); + return ( + + + + + + + {title} + + + {description} + + + {/* Always occupies its slot. Rendering the checkmark only when selected + narrowed the copy column on tap, which re-wrapped the longer + descriptions and changed the height of the whole page. */} + + {selected ? ( + + ) : ( + + )} + + + ); +} + +const useStyles = createThemedStyles((colors) => ({ + stepBody: { + width: '100%', + gap: spacing.lg, + }, + preview: { + flexDirection: 'row', + justifyContent: 'center', + gap: spacing.xl, + }, + previewItem: { + alignItems: 'center', + gap: spacing.sm, + }, + previewCircleWrap: { + width: 56, + height: 56, + alignItems: 'center', + justifyContent: 'center', + }, + previewCircleLayer: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + borderRadius: 28, + }, + previewCircleNeutral: { + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + backgroundColor: colors.bgTertiary, + }, + previewCircleAccent: { + borderWidth: 1, + borderColor: colors.accent, + backgroundColor: colors.accentGlow, + }, + previewAlbumSquare: { + width: 24, + height: 24, + borderRadius: 4, + borderWidth: 1.5, + borderColor: colors.textTertiary, + }, + previewNameLine: { + width: 34, + height: 5, + borderRadius: 2.5, + backgroundColor: colors.glassBorder, + }, + options: { + gap: spacing.sm, + }, + card: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + minHeight: 68, + paddingVertical: spacing.md, + paddingHorizontal: spacing.md, + borderRadius: radius.md, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + backgroundColor: colors.glassBg, + }, + cardSelected: { + borderWidth: 1, + borderColor: colors.accent, + backgroundColor: colors.accentGlow, + }, + cardIcon: { + width: 38, + height: 38, + borderRadius: 19, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.bgTertiary, + }, + cardIconSelected: { + backgroundColor: colors.bgSecondary, + }, + cardCopy: { + flex: 1, + minWidth: 0, + gap: 2, + }, + cardCheck: { + width: 20, + height: 20, + alignItems: 'center', + justifyContent: 'center', + }, + cardCheckEmpty: { + width: 16, + height: 16, + borderRadius: 8, + borderWidth: 1.5, + borderColor: colors.glassBorder, + }, + footnote: { + textAlign: 'center', + }, +})); + +export default ArtistImageStep; diff --git a/src/components/onboarding/NotificationStep.tsx b/src/components/onboarding/NotificationStep.tsx new file mode 100644 index 0000000..af30001 --- /dev/null +++ b/src/components/onboarding/NotificationStep.tsx @@ -0,0 +1,195 @@ +import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { Text } from '@/components/Text'; +import { StepHeader } from '@/components/onboarding/StepHeader'; +import { radius, spacing } from '@/theme'; +import { createThemedStyles, useColors } from '@/theme/themed'; +import { useRipple } from '@/theme/ripple'; +import { useLibraryStore } from '@/stores/libraryStore'; +import type { ScanNotificationPermission } from '@/library/useScanNotificationPermission'; + +/** + * Wizard page for the POST_NOTIFICATIONS grant. Deliberately not the settings + * `ScanNotificationPermissionCard`: with a whole page to work with, the ask gets + * a sketch of the actual notification and one unmistakable action instead of a + * dense list row. The permission state is owned by OnboardingFlow so the footer + * button can say "Continue" vs "Skip for now" from the same source of truth. + */ +export function NotificationStep({ + permission, +}: { + permission: ScanNotificationPermission; +}) { + const styles = useStyles(); + const colors = useColors(); + const ripple = useRipple(); + const isScanning = useLibraryStore((s) => s.isScanning); + const { state, granted, denied, working, resolve } = permission; + + return ( + + + + + + {state === null ? ( + + ) : granted ? ( + + + + {state === 'not_required' + ? 'No permission needed on this Android version' + : 'Notifications allowed'} + + + ) : ( + + {working ? ( + + ) : ( + + )} + + {denied ? 'Open Android settings' : 'Allow notifications'} + + + )} + + + {denied + ? 'Notifications are currently blocked for Astra. Scans still work — they just stop early if Android needs the memory.' + : 'Optional. Scans still work without it, but Android may stop a long scan once you leave the app.'} + + + ); +} + +/** + * Miniature of the real scan notification. Static numbers on purpose — a live + * counter here would compete with the actual ScanBanner above the page. + */ +function NotificationSketch() { + const styles = useStyles(); + const colors = useColors(); + return ( + + + + + + + Astra + + + + now + + + + Scanning your library + + + 1,204 of 3,180 tracks + + + + + + ); +} + +const useStyles = createThemedStyles((colors) => ({ + stepBody: { + width: '100%', + gap: spacing.lg, + }, + sketch: { + gap: spacing.xs, + padding: spacing.md, + borderRadius: radius.lg, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + backgroundColor: colors.bgSecondary, + }, + sketchHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + marginBottom: 2, + }, + sketchAppIcon: { + width: 18, + height: 18, + borderRadius: 5, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.accent, + }, + sketchSeparator: { + width: 3, + height: 3, + borderRadius: 1.5, + backgroundColor: colors.textTertiary, + }, + sketchTrack: { + height: 4, + borderRadius: 2, + overflow: 'hidden', + backgroundColor: colors.bgTertiary, + marginTop: spacing.xs, + }, + sketchFill: { + width: '38%', + height: '100%', + borderRadius: 2, + backgroundColor: colors.accent, + }, + button: { + minHeight: 52, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.lg, + borderRadius: radius.md, + backgroundColor: colors.accentGlow, + overflow: 'hidden', + }, + grantedRow: { + minHeight: 52, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.lg, + borderRadius: radius.md, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + backgroundColor: colors.glassBg, + }, + footnote: { + textAlign: 'center', + }, +})); + +export default NotificationStep; diff --git a/src/components/onboarding/OnboardingFlow.tsx b/src/components/onboarding/OnboardingFlow.tsx index d55d52e..03df019 100644 --- a/src/components/onboarding/OnboardingFlow.tsx +++ b/src/components/onboarding/OnboardingFlow.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, type ComponentProps } from 'react'; +import { useEffect, useState } from 'react'; import { ActivityIndicator, Pressable, @@ -23,6 +23,9 @@ import { Text } from '@/components/Text'; import { ScanProgress } from '@/components/library/ScanProgress'; import { AccentSwatchRow } from '@/components/settings/AccentSwatchRow'; import { ScopeStyleCards } from '@/components/settings/ScopeStyleCards'; +import { StepHeader } from '@/components/onboarding/StepHeader'; +import { NotificationStep } from '@/components/onboarding/NotificationStep'; +import { ArtistImageStep } from '@/components/onboarding/ArtistImageStep'; import { formatFolderCount, formatTrackCount } from '@/components/settings/SettingsPanels'; import { radius, spacing } from '@/theme'; import { motion } from '@/theme/motion'; @@ -30,14 +33,33 @@ import { createThemedStyles, useColors } from '@/theme/themed'; import { useRipple } from '@/theme/ripple'; import { playHaptic } from '@/lib/haptics'; import type { BaseThemeId } from '@/theme/resolve'; +import { useScanNotificationPermission } from '@/library/useScanNotificationPermission'; import { useLibraryStore } from '@/stores/libraryStore'; import { useSettingsStore, type NowPlayingScopeStyle } from '@/stores/settingsStore'; import { useThemeStore } from '@/stores/themeStore'; -type IoniconName = ComponentProps['name']; -type StepId = 'welcome' | 'library' | 'theme' | 'player' | 'done'; +type StepId = + | 'welcome' + | 'library' + | 'notifications' + | 'artistImages' + | 'theme' + | 'player' + | 'done'; -const STEP_ORDER: StepId[] = ['welcome', 'library', 'theme', 'player', 'done']; +// Folders first so the notification ask lands with a visible reason ("your scan +// is running"), then the Deezer consent. Each is its own page: they are three +// unrelated decisions and stacking them made the library step's real action — +// picking a folder — the third thing on screen. +const STEP_ORDER: StepId[] = [ + 'welcome', + 'library', + 'notifications', + 'artistImages', + 'theme', + 'player', + 'done', +]; const WIZARD_THEME_OPTIONS: { id: BaseThemeId; title: string }[] = [ { id: 'system', title: 'System' }, @@ -63,6 +85,9 @@ export function OnboardingFlow({ onDone }: { onDone: () => void }) { const step = STEP_ORDER[stepIndex]; const foldersCount = useLibraryStore((s) => s.folders.length); const isScanning = useLibraryStore((s) => s.isScanning); + // Owned here so the footer label can distinguish "Continue" from "Skip for + // now" using the same state the notification step renders. + const notificationPermission = useScanNotificationPermission(); // Deliberately unset until tapped: preselecting a card would bias the // pre-release style feedback. Skipping through keeps the store default. const [scopeStyleChoice, setScopeStyleChoice] = useState(null); @@ -77,7 +102,7 @@ export function OnboardingFlow({ onDone }: { onDone: () => void }) { }; const goBack = () => setStepIndex((i) => Math.max(0, i - 1)); - const canGoBack = step === 'library' || step === 'theme' || step === 'player'; + const canGoBack = stepIndex > 0 && step !== 'done'; const primaryLabel = step === 'welcome' ? 'Get started' @@ -88,9 +113,14 @@ export function OnboardingFlow({ onDone }: { onDone: () => void }) { foldersCount > 0 || isScanning ? 'Continue' : 'Skip for now' - : step === 'theme' || step === 'player' - ? 'Continue' - : 'Start listening'; + : step === 'notifications' + ? // The grant is optional, so moving on without it really is skipping. + notificationPermission.granted + ? 'Continue' + : 'Skip for now' + : step === 'artistImages' || step === 'theme' || step === 'player' + ? 'Continue' + : 'Start listening'; return ( @@ -123,6 +153,10 @@ export function OnboardingFlow({ onDone }: { onDone: () => void }) { {step === 'welcome' ? : null} {step === 'library' ? : null} + {step === 'notifications' ? ( + + ) : null} + {step === 'artistImages' ? : null} {step === 'theme' ? : null} {step === 'player' ? ( @@ -203,6 +237,7 @@ function LibraryStep() { title="Add your music" subtitle="Point Astra at the folders where your music lives. It scans them into your library — files on disk are never modified." /> + - - - - - {title} - - - {subtitle} - - - ); -} - /** Subtle "still scanning" pill shown at the top of steps after the library step. */ function ScanBanner() { const styles = useStyles(); @@ -496,22 +505,6 @@ const useStyles = createThemedStyles((colors) => ({ width: '100%', gap: spacing.lg, }, - stepHeader: { - alignItems: 'center', - gap: spacing.sm, - marginBottom: spacing.xs, - }, - stepIconWrap: { - width: 56, - height: 56, - borderRadius: 28, - backgroundColor: colors.glassBg, - borderWidth: StyleSheet.hairlineWidth, - borderColor: colors.glassBorder, - alignItems: 'center', - justifyContent: 'center', - marginBottom: spacing.xs, - }, choiceButton: { flexDirection: 'row', alignItems: 'center', diff --git a/src/components/onboarding/StepHeader.tsx b/src/components/onboarding/StepHeader.tsx new file mode 100644 index 0000000..5617f10 --- /dev/null +++ b/src/components/onboarding/StepHeader.tsx @@ -0,0 +1,67 @@ +import type { ComponentProps } from 'react'; +import { StyleSheet, View } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { Text } from '@/components/Text'; +import { spacing } from '@/theme'; +import { createThemedStyles, useColors } from '@/theme/themed'; + +type IoniconName = ComponentProps['name']; + +/** + * Shared masthead for every wizard page: medallion icon, title, and the one-line + * reason the page exists. Lives outside OnboardingFlow so the individual step + * files can use it without importing their own parent. + */ +export function StepHeader({ + icon, + title, + subtitle, +}: { + icon: IoniconName; + title: string; + subtitle: string; +}) { + const styles = useStyles(); + const colors = useColors(); + return ( + + + + + + {title} + + + {subtitle} + + + ); +} + +const useStyles = createThemedStyles((colors) => ({ + stepHeader: { + alignItems: 'center', + gap: spacing.sm, + marginBottom: spacing.xs, + }, + stepIconWrap: { + width: 56, + height: 56, + borderRadius: 28, + backgroundColor: colors.glassBg, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + alignItems: 'center', + justifyContent: 'center', + marginBottom: spacing.xs, + }, + title: { + textAlign: 'center', + }, + subtitle: { + textAlign: 'center', + maxWidth: 340, + }, +})); + +export default StepHeader; diff --git a/src/components/settings/SettingsPanels.tsx b/src/components/settings/SettingsPanels.tsx index e5015e0..a56fae4 100644 --- a/src/components/settings/SettingsPanels.tsx +++ b/src/components/settings/SettingsPanels.tsx @@ -6,6 +6,8 @@ import { import { Ionicons } from '@expo/vector-icons'; import { EQSlider } from '@/components/eq/EQSlider'; import { ScanProgress } from '@/components/library/ScanProgress'; +import { ScanNotificationPermissionCard } from '@/components/library/ScanNotificationPermissionCard'; +import { ArtistImageSweepStatus } from '@/components/library/ArtistImageSweepStatus'; import { SegmentedControl } from '@/components/SegmentedControl'; import { AccentSwatchRow } from '@/components/settings/AccentSwatchRow'; import { ScopeStyleCards } from '@/components/settings/ScopeStyleCards'; @@ -393,11 +395,51 @@ export function LibrarySettingsPanel() { const setIncludeSingles = useSettingsStore((s) => s.setIncludeSingles); const includeCollabArtists = useLibraryStore((s) => s.includeCollabArtists); const setIncludeCollabArtists = useLibraryStore((s) => s.setIncludeCollabArtists); + const artistImageAutoPolicy = useSettingsStore((s) => s.artistImageAutoPolicy); + const setArtistImageAutoPolicy = useSettingsStore((s) => s.setArtistImageAutoPolicy); + const artistImagesEnabled = artistImageAutoPolicy !== 'off'; return ( <> LOCAL FOLDERS + {/* Renders nothing once the permission is granted — the style carries the + spacing so no empty gap is left behind. */} + + + ARTIST IMAGES + + + void setArtistImageAutoPolicy(enabled ? 'wifi' : 'off') + } + /> + {artistImagesEnabled ? ( + + + Download network + + + void setArtistImageAutoPolicy(value === 'any' ? 'any' : 'wifi') + } + /> + + ) : ( + + Manual Deezer searches remain available from an artist’s menu. + + )} + + LIBRARY VIEW diff --git a/src/library/artistGrouping.ts b/src/library/artistGrouping.ts index b0d5f72..c516273 100644 --- a/src/library/artistGrouping.ts +++ b/src/library/artistGrouping.ts @@ -244,6 +244,7 @@ export function buildArtistList(tracks: readonly ArtistTrackLike[], mode: Artist track_count, primary_track_count, artwork_hash, + artwork_source: artwork_hash ? 'track' as const : null, album_count: albumKeys.size, artwork_hashes, }; diff --git a/src/library/artistImageLookup.ts b/src/library/artistImageLookup.ts new file mode 100644 index 0000000..62fb6ac --- /dev/null +++ b/src/library/artistImageLookup.ts @@ -0,0 +1,326 @@ +import * as Network from 'expo-network'; +import type { DeezerArtistCandidate } from '@/types/artistImages'; +import { + AstraLibraryData, + AstraLibraryScanner, + type NativeArtistImageLookupTarget, +} from '../../modules/astra-library-scanner'; +import { useSettingsStore } from '@/stores/settingsStore'; +import { useArtistImageStore } from '@/stores/artistImageStore'; +import { endServiceFor, reportServiceProgress } from '@/library/scanService'; +import { + canAutomaticallyDownloadArtistImages, + artistImageRetryBackoff, + groupArtistImageTargetsByName, +} from './artistImagePolicy'; +import { + DEEZER_ARTIST_IMAGES_ENABLED, + pickAutomaticDeezerCandidate, + searchDeezerArtists, +} from '@/services/artistImages/deezer'; +import { cacheRemoteArtistImage } from '@/services/artistImages/cache'; + +const PAGE_SIZE = 100; +const CACHE_RETRY_MS = 30 * 60 * 1000; +// Deezer allows roughly 50 requests per 5 seconds and answers a breach with a +// 429 that parks the whole queue for six hours. Spacing requests keeps a +// full-library sweep — which is exactly what a rescan triggers — under that. +const REQUEST_SPACING_MS = 150; +// Below this, a sweep finishes in a few seconds and a notification would be +// pure noise — adding one album should not light up the shade. Larger sweeps +// take minutes and need the foreground service to survive backgrounding. +const NOTIFICATION_THRESHOLD = 25; + +/** + * Native-backed so the sweep keeps pacing itself while Astra is backgrounded. + * `setTimeout` stops firing the moment the activity pauses (React Native drops + * the Choreographer callback that drives timers), which stalled the sweep at the + * first gap between artists even with the foreground service holding the process + * open. Falls back to a JS timer if the native build predates the method. + */ +function delay(ms: number): Promise { + if (typeof AstraLibraryScanner.backgroundDelay === 'function') { + return AstraLibraryScanner.backgroundDelay(ms); + } + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +const n = (value: number) => value.toLocaleString(); + +function publishSweepProgress(announced: boolean): void { + if (!announced) return; + const { processed, total } = useArtistImageStore.getState(); + reportServiceProgress('artistImages', { + title: 'Finding artist images', + text: total > 0 ? `${n(processed)} of ${n(total)} artists` : 'Looking up artists…', + subText: null, + current: processed, + total, + indeterminate: total <= 0, + }); +} + +let started = false; +let running = false; +let runAgain = false; +let debounceTimer: ReturnType | null = null; +let retryTimer: ReturnType | null = null; +let activeAutomaticLookup: AbortController | null = null; + +async function networkAllowsAutomaticDownloads(): Promise { + if (!DEEZER_ARTIST_IMAGES_ENABLED) return false; + const settings = useSettingsStore.getState(); + if (!settings.loaded) return false; + const network = await Network.getNetworkStateAsync(); + return canAutomaticallyDownloadArtistImages( + settings.artistImageAutoPolicy, + settings.artistImageDisclosureSeen, + network + ); +} + +function setRetryTimer(delayMs: number): void { + if (retryTimer) clearTimeout(retryTimer); + retryTimer = setTimeout(() => { + retryTimer = null; + scheduleArtistImageLookups(); + }, Math.min(delayMs, 24 * 60 * 60 * 1000)); +} + +async function persistLookup( + targets: NativeArtistImageLookupTarget[], + values: Parameters[3] +): Promise { + await Promise.all( + targets.map((target) => + AstraLibraryData.recordArtistImageLookup( + target.artistKey, + target.artistName, + target.groupingMode, + values + ) + ) + ); +} + +async function processTargetGroup( + targets: NativeArtistImageLookupTarget[] +): Promise<'continue' | 'pause'> { + const attemptedAt = Date.now(); + activeAutomaticLookup = new AbortController(); + const result = await searchDeezerArtists( + targets[0].artistName, + activeAutomaticLookup.signal + ); + activeAutomaticLookup = null; + if (result.status === 'transient_error') { + if (result.code === 'cancelled') return 'pause'; + const retryAfterMs = artistImageRetryBackoff( + result.retryAfterMs, + targets.map((target) => target.retryCount ?? 0) + ); + const nextRetryAt = attemptedAt + retryAfterMs; + await persistLookup(targets, { + status: 'transient_error', + attemptedAt, + nextRetryAt, + }); + setRetryTimer(retryAfterMs); + return 'pause'; + } + + const candidate = pickAutomaticDeezerCandidate( + targets[0].artistName, + result.candidates + ); + if (!candidate) { + await persistLookup(targets, { status: 'not_found', attemptedAt }); + return 'continue'; + } + + try { + if (!(await networkAllowsAutomaticDownloads())) return 'pause'; + const automaticImageHash = await cacheRemoteArtistImage(candidate.imageUrl); + if (!(await networkAllowsAutomaticDownloads())) return 'pause'; + await persistLookup(targets, { + status: 'found', + attemptedAt, + automaticImageHash, + provider: 'deezer', + sourceId: candidate.id, + }); + return 'continue'; + } catch { + const retryAfterMs = artistImageRetryBackoff( + CACHE_RETRY_MS, + targets.map((target) => target.retryCount ?? 0) + ); + await persistLookup(targets, { + status: 'transient_error', + attemptedAt, + nextRetryAt: attemptedAt + retryAfterMs, + }); + setRetryTimer(retryAfterMs); + return 'pause'; + } +} + +async function drainArtistImageQueue(): Promise { + if (running) { + runAgain = true; + return; + } + running = true; + let started = false; + let announced = false; + try { + do { + runAgain = false; + if (!(await networkAllowsAutomaticDownloads())) return; + const targets = await AstraLibraryData.getPendingArtistImageLookups( + PAGE_SIZE, + Date.now() + ); + if (targets.length === 0) return; + + if (!started) { + started = true; + // Counted once for the whole sweep: re-counting per page would shrink + // the denominator as the queue drains and the bar would never advance. + const { pending } = await AstraLibraryData.getArtistImageStats( + useSettingsStore.getState().artistGroupingMode, + Date.now() + ); + useArtistImageStore.getState().beginSweep(pending); + announced = pending >= NOTIFICATION_THRESHOLD; + publishSweepProgress(announced); + } + + let first = true; + for (const group of groupArtistImageTargetsByName(targets)) { + if (!(await networkAllowsAutomaticDownloads())) return; + // Between groups only: never delays the first lookup after a change. + if (!first) await delay(REQUEST_SPACING_MS); + first = false; + if ((await processTargetGroup(group)) === 'pause') return; + // One group is one provider request, so this matches the denominator. + useArtistImageStore.getState().advanceSweep(); + publishSweepProgress(announced); + } + runAgain = targets.length >= PAGE_SIZE; + } while (runAgain); + } finally { + running = false; + if (started) { + useArtistImageStore.getState().endSweep(); + // No-op when the sweep stayed under the threshold and never claimed it. + endServiceFor('artistImages'); + } + if (runAgain) scheduleArtistImageLookups(); + } +} + +export function scheduleArtistImageLookups(): void { + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + debounceTimer = null; + void drainArtistImageQueue(); + }, 750); +} + +export function startArtistImageLookupCoordinator(): void { + if (started) return; + started = true; + AstraLibraryData.addListener('onCatalogChanged', scheduleArtistImageLookups); + Network.addNetworkStateListener((network) => { + const settings = useSettingsStore.getState(); + if ( + !canAutomaticallyDownloadArtistImages( + settings.artistImageAutoPolicy, + settings.artistImageDisclosureSeen, + network + ) + ) { + activeAutomaticLookup?.abort(); + } + scheduleArtistImageLookups(); + }); + useSettingsStore.subscribe((state, previous) => { + if ( + state.artistImageAutoPolicy !== previous.artistImageAutoPolicy || + state.artistImageDisclosureSeen !== previous.artistImageDisclosureSeen || + state.loaded !== previous.loaded + ) { + void networkAllowsAutomaticDownloads().then((allowed) => { + if (!allowed) activeAutomaticLookup?.abort(); + }); + scheduleArtistImageLookups(); + } + }); + scheduleArtistImageLookups(); +} + +export async function searchArtistImageCandidates(query: string) { + return searchDeezerArtists(query); +} + +/** + * Makes artists a provider previously had no match for eligible again, then + * kicks the queue. Called when a scan finishes so "rescan" also re-checks the + * artists that came back empty — `not_found` is terminal in the pending query, + * so nothing else ever revisits them. + */ +export async function requeueMissingArtistImages(): Promise { + try { + const cleared = await AstraLibraryData.clearArtistImageLookupFailures(); + if (cleared > 0) scheduleArtistImageLookups(); + } catch (error) { + // A scan must never fail because the retry sweep could not be queued. + console.warn('[artistImages] could not re-queue missing artist images', error); + } +} + +export async function selectDeezerArtistImage( + artistKey: string, + artistName: string, + groupingMode: 'astra' | 'fileTags', + candidate: DeezerArtistCandidate +): Promise { + const automaticImageHash = await cacheRemoteArtistImage(candidate.imageUrl); + await AstraLibraryData.recordArtistImageLookup( + artistKey, + artistName, + groupingMode, + { + status: 'found', + automaticImageHash, + provider: 'deezer', + sourceId: candidate.id, + attemptedAt: Date.now(), + clearManual: true, + } + ); +} + +export async function selectLocalArtistImage( + artistKey: string, + artistName: string, + groupingMode: 'astra' | 'fileTags', + uri: string +): Promise { + const artworkHash = await AstraLibraryScanner.cacheArtworkFromUri(uri); + await AstraLibraryData.setManualArtistImage( + artistKey, + artistName, + groupingMode, + artworkHash + ); +} + +export async function resetLocalArtistImage( + artistKey: string, + artistName: string, + groupingMode: 'astra' | 'fileTags' +): Promise { + await AstraLibraryData.clearManualArtistImage(artistKey, artistName, groupingMode); +} diff --git a/src/library/artistImagePolicy.test.mts b/src/library/artistImagePolicy.test.mts new file mode 100644 index 0000000..9b9753b --- /dev/null +++ b/src/library/artistImagePolicy.test.mts @@ -0,0 +1,52 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + canAutomaticallyDownloadArtistImages, + artistImageRetryBackoff, + groupArtistImageTargetsByName, +} from './artistImagePolicy.ts'; + +const network = ( + type: 'WIFI' | 'ETHERNET' | 'CELLULAR' | 'NONE', + isConnected = true, + isInternetReachable: boolean | null = true +) => ({ type, isConnected, isInternetReachable }) as never; + +test('automatic policy stays blocked before disclosure and while off', () => { + assert.equal(canAutomaticallyDownloadArtistImages('wifi', false, network('WIFI')), false); + assert.equal(canAutomaticallyDownloadArtistImages('off', true, network('WIFI')), false); +}); + +test('Wi-Fi policy permits Wi-Fi and Ethernet but not cellular', () => { + assert.equal(canAutomaticallyDownloadArtistImages('wifi', true, network('WIFI')), true); + assert.equal(canAutomaticallyDownloadArtistImages('wifi', true, network('ETHERNET')), true); + assert.equal(canAutomaticallyDownloadArtistImages('wifi', true, network('CELLULAR')), false); +}); + +test('any-network policy still pauses offline or without reachable internet', () => { + assert.equal(canAutomaticallyDownloadArtistImages('any', true, network('CELLULAR')), true); + assert.equal(canAutomaticallyDownloadArtistImages('any', true, network('NONE', false)), false); + assert.equal( + canAutomaticallyDownloadArtistImages('any', true, network('WIFI', true, false)), + false + ); +}); + +test('same normalized artist is deduplicated across grouping modes', () => { + const groups = groupArtistImageTargetsByName([ + { groupingMode: 'astra', artistKey: 'björk', artistName: 'Björk' }, + { groupingMode: 'fileTags', artistKey: 'bjork', artistName: 'BJORK' }, + { groupingMode: 'astra', artistKey: 'radiohead', artistName: 'Radiohead' }, + ]); + assert.equal(groups.length, 2); + assert.equal(groups[0].length, 2); + assert.equal(groups[1].length, 1); +}); + +test('transient retries back off exponentially and cap at one day', () => { + const halfHour = 30 * 60 * 1000; + assert.equal(artistImageRetryBackoff(halfHour, [0]), halfHour); + assert.equal(artistImageRetryBackoff(halfHour, [1]), halfHour * 2); + assert.equal(artistImageRetryBackoff(halfHour, [3, 2]), halfHour * 8); + assert.equal(artistImageRetryBackoff(6 * 60 * 60 * 1000, [4]), 24 * 60 * 60 * 1000); +}); diff --git a/src/library/artistImagePolicy.ts b/src/library/artistImagePolicy.ts new file mode 100644 index 0000000..5293fa9 --- /dev/null +++ b/src/library/artistImagePolicy.ts @@ -0,0 +1,43 @@ +import type { NetworkState, NetworkStateType } from 'expo-network'; +import type { + ArtistImageAutoPolicy, + ArtistImageLookupTarget, +} from '../types/artistImages.ts'; +import { normalizeArtistImageMatchName } from '../services/artistImages/deezer.ts'; + +export function canAutomaticallyDownloadArtistImages( + policy: ArtistImageAutoPolicy, + disclosureSeen: boolean, + network: Pick +): boolean { + if (!disclosureSeen || policy === 'off') return false; + if (!network.isConnected || network.isInternetReachable === false) return false; + if (policy === 'any') return true; + return ( + network.type === ('WIFI' as NetworkStateType) || + network.type === ('ETHERNET' as NetworkStateType) + ); +} + +export function groupArtistImageTargetsByName( + targets: T[] +): T[][] { + const groups = new Map(); + for (const target of targets) { + const key = normalizeArtistImageMatchName(target.artistName); + if (!key) continue; + const group = groups.get(key); + if (group) group.push(target); + else groups.set(key, [target]); + } + return [...groups.values()]; +} + +export function artistImageRetryBackoff( + baseMs: number, + retryCounts: number[], + maximumMs = 24 * 60 * 60 * 1000 +): number { + const retryCount = Math.max(0, ...retryCounts); + return Math.min(baseMs * 2 ** Math.min(retryCount, 5), maximumMs); +} diff --git a/src/library/nativePages.ts b/src/library/nativePages.ts index a72386e..a236179 100644 --- a/src/library/nativePages.ts +++ b/src/library/nativePages.ts @@ -111,9 +111,23 @@ export function useNativeArtistDetail( useEffect(() => { queueMicrotask(() => void reset()); - const subscription = AstraLibraryData.addListener('onCatalogChanged', () => void reset()); - return () => subscription.remove(); - }, [reset]); + const catalogSubscription = AstraLibraryData.addListener( + 'onCatalogChanged', + () => void reset() + ); + const imageSubscription = AstraLibraryData.addListener( + 'onArtistImagesChanged', + (event) => { + if (event.artistKey === artistKey && event.groupingMode === groupingMode) { + void reset(); + } + } + ); + return () => { + catalogSubscription.remove(); + imageSubscription.remove(); + }; + }, [artistKey, groupingMode, reset]); const loadMore = useCallback(async () => { if (!cursor || loading) return; diff --git a/src/library/scanService.ts b/src/library/scanService.ts index 23b8fdf..93b6b45 100644 --- a/src/library/scanService.ts +++ b/src/library/scanService.ts @@ -4,37 +4,67 @@ // This starts the FGS on the first progress tick and tears it down when the scan ends. // All no-ops on non-Android and on native binaries built before the FGS methods existed. -import { PermissionsAndroid, Platform } from 'react-native'; -import { AstraLibraryScanner } from '../../modules/astra-library-scanner'; +import { Linking, PermissionsAndroid, Platform } from 'react-native'; +import { AstraLibraryData, AstraLibraryScanner } from '../../modules/astra-library-scanner'; import type { ScanProgress } from './scanner'; const supported = Platform.OS === 'android' && typeof (AstraLibraryScanner as { startScanService?: unknown }).startScanService === 'function'; -// runScan guarantees scans never overlap, so single-scan module state is safe. -let active = false; -let notifPermRequested = false; - /** - * POST_NOTIFICATIONS is Android 13+ (API 33); PermissionsAndroid resolves it granted - * automatically below that. Requested contextually on the first scan. The FGS + - * wakelock still keep the scan alive without it — only the visible notification needs it. + * Who currently needs the foreground service. A scan is no longer the only + * producer — the artist-image sweep runs on the same JS thread and starts right + * after a scan finishes — so ownership is ref-counted: the service starts when + * the set becomes non-empty and stops only when the last owner releases it. + * A plain boolean here would let either side tear down the other's keepalive. */ -async function ensureNotificationPermission(): Promise { - if (notifPermRequested) return; - notifPermRequested = true; +type ServiceOwner = 'scan' | 'artistImages'; +const owners = new Set(); +const latest = new Map(); + +const NOTIFICATION_PERMISSION_REQUESTED_KEY = 'scan_notification_permission_requested'; + +export type ScanNotificationPermissionState = + | 'not_required' + | 'prompt' + | 'granted' + | 'denied'; + +function notificationPermission(): Parameters[0] | null { + if (Platform.OS !== 'android' || Number(Platform.Version) < 33) return null; const permission = (PermissionsAndroid.PERMISSIONS as Record) .POST_NOTIFICATIONS; - if (!permission) return; + return permission + ? (permission as Parameters[0]) + : null; +} + +export async function getScanNotificationPermissionState(): Promise { + const permission = notificationPermission(); + if (!permission) return 'not_required'; + if (await PermissionsAndroid.check(permission)) return 'granted'; + const values = await AstraLibraryData.getSettings([NOTIFICATION_PERMISSION_REQUESTED_KEY]); + return values[NOTIFICATION_PERMISSION_REQUESTED_KEY] === '1' ? 'denied' : 'prompt'; +} + +export async function requestScanNotificationPermission(): Promise { + const permission = notificationPermission(); + if (!permission) return 'not_required'; + await AstraLibraryData.setSettings({ [NOTIFICATION_PERMISSION_REQUESTED_KEY]: '1' }); try { - await PermissionsAndroid.request(permission as Parameters[0]); + const result = await PermissionsAndroid.request(permission); + return result === PermissionsAndroid.RESULTS.GRANTED ? 'granted' : 'denied'; } catch { - // Denied/unavailable — the scan still runs, the notification just won't show. + return 'denied'; } } -interface ScanNotification { +export async function openScanNotificationSettings(): Promise { + await Linking.openSettings(); +} + +export interface ScanNotification { title: string; text: string; subText: string | null; @@ -77,22 +107,57 @@ function notificationFor(progress: ScanProgress): ScanNotification { }; } -/** Report a scan progress tick — starts the FGS on the first call, updates it after. */ -export async function reportScanProgress(progress: ScanProgress): Promise { +/** + * Publish a progress tick for one owner — starts the service on the first call, + * updates the notification after. A scan outranks the sweep when both are live: + * it is the operation the user just asked for, and it finishes sooner. + */ +export function reportServiceProgress( + owner: ServiceOwner, + notification: ScanNotification +): void { if (!supported) return; - const { title, text, subText, current, total, indeterminate } = notificationFor(progress); - if (!active) { - active = true; - await ensureNotificationPermission(); - if (!active) return; // scan ended while we awaited the permission dialog - AstraLibraryScanner.startScanService(title, text); + const starting = owners.size === 0; + owners.add(owner); + latest.set(owner, notification); + if (starting) { + AstraLibraryScanner.startScanService(notification.title, notification.text); } - AstraLibraryScanner.updateScanNotification(title, text, subText, current, total, indeterminate); + publish(); } -/** Tear down the scan foreground service when a scan finishes (or errors). */ -export function endScanService(): void { - if (!supported || !active) return; - active = false; +/** Release one owner's claim; the service stops once nobody holds it. */ +export function endServiceFor(owner: ServiceOwner): void { + if (!supported || !owners.has(owner)) return; + owners.delete(owner); + latest.delete(owner); + if (owners.size > 0) { + publish(); + return; + } AstraLibraryScanner.stopScanService(); } + +function publish(): void { + const owner: ServiceOwner = owners.has('scan') ? 'scan' : 'artistImages'; + const next = latest.get(owner); + if (!next) return; + AstraLibraryScanner.updateScanNotification( + next.title, + next.text, + next.subText, + next.current, + next.total, + next.indeterminate + ); +} + +/** Report a scan progress tick — starts the FGS on the first call, updates it after. */ +export async function reportScanProgress(progress: ScanProgress): Promise { + reportServiceProgress('scan', notificationFor(progress)); +} + +/** Tear down the scan's claim on the foreground service when a scan finishes (or errors). */ +export function endScanService(): void { + endServiceFor('scan'); +} diff --git a/src/library/useScanNotificationPermission.ts b/src/library/useScanNotificationPermission.ts new file mode 100644 index 0000000..957cb9b --- /dev/null +++ b/src/library/useScanNotificationPermission.ts @@ -0,0 +1,64 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useAppForeground } from '@/lib/useAppForeground'; +import { + getScanNotificationPermissionState, + openScanNotificationSettings, + requestScanNotificationPermission, + type ScanNotificationPermissionState, +} from '@/library/scanService'; + +export interface ScanNotificationPermission { + /** null until the first native check resolves. */ + state: ScanNotificationPermissionState | null; + /** True once the permission is held, or when the OS never required it. */ + granted: boolean; + /** Already refused once — `resolve` opens Android settings instead of re-asking. */ + denied: boolean; + working: boolean; + /** Requests the permission, or opens Android settings once it has been denied. */ + resolve: () => void; +} + +/** + * Shared POST_NOTIFICATIONS state for the scan progress notification, used by + * both the settings row and the onboarding step. Re-checks on every return to + * the foreground because the grant can be flipped in Android settings while + * Astra is backgrounded. + */ +export function useScanNotificationPermission(): ScanNotificationPermission { + const [state, setState] = useState(null); + const [working, setWorking] = useState(false); + const foreground = useAppForeground(); + + useEffect(() => { + if (!foreground) return; + let cancelled = false; + void getScanNotificationPermissionState().then((next) => { + if (!cancelled) setState(next); + }); + return () => { + cancelled = true; + }; + }, [foreground]); + + const denied = state === 'denied'; + const resolve = useCallback(() => { + if (working) return; + if (denied) { + void openScanNotificationSettings(); + return; + } + setWorking(true); + void requestScanNotificationPermission() + .then(setState) + .finally(() => setWorking(false)); + }, [denied, working]); + + return { + state, + granted: state === 'granted' || state === 'not_required', + denied, + working, + resolve, + }; +} diff --git a/src/services/artistImages/cache.ts b/src/services/artistImages/cache.ts new file mode 100644 index 0000000..b70751c --- /dev/null +++ b/src/services/artistImages/cache.ts @@ -0,0 +1,22 @@ +import { + cacheDirectory, + deleteAsync, + downloadAsync, +} from 'expo-file-system/legacy'; +import { AstraLibraryScanner } from '../../../modules/astra-library-scanner'; + +export async function cacheRemoteArtistImage(imageUrl: string): Promise { + if (!cacheDirectory) throw new Error('The image cache is unavailable.'); + const temporaryUri = + `${cacheDirectory}artist-image-${Date.now()}-` + + `${Math.random().toString(36).slice(2)}.download`; + try { + const result = await downloadAsync(imageUrl, temporaryUri); + if (result.status < 200 || result.status >= 300) { + throw new Error(`Image download failed (${result.status}).`); + } + return await AstraLibraryScanner.cacheArtworkFromUri(result.uri); + } finally { + await deleteAsync(temporaryUri, { idempotent: true }).catch(() => undefined); + } +} diff --git a/src/services/artistImages/deezer.test.mts b/src/services/artistImages/deezer.test.mts new file mode 100644 index 0000000..93e01c0 --- /dev/null +++ b/src/services/artistImages/deezer.test.mts @@ -0,0 +1,134 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + normalizeArtistImageMatchName, + parseDeezerArtistPayload, + pickAutomaticDeezerCandidate, + searchDeezerArtists, +} from './deezer.ts'; +import type { DeezerArtistCandidate } from '../../types/artistImages.ts'; + +function candidate( + id: string, + name: string, + fanCount: number +): DeezerArtistCandidate { + return { + provider: 'deezer', + id, + name, + fanCount, + albumCount: 1, + imageUrl: `https://example.test/${id}.jpg`, + linkUrl: `https://www.deezer.com/artist/${id}`, + }; +} + +test('normalizes Unicode, punctuation, case, and whitespace for exact matching', () => { + assert.equal(normalizeArtistImageMatchName(' Sigur Rós! '), 'sigur ros'); + assert.equal(normalizeArtistImageMatchName('BOA'), 'boa'); + assert.equal(normalizeArtistImageMatchName('AC/DC'), 'ac dc'); +}); + +// Every real Deezer CDN URL ends in the "-000000-80-0-0" transform suffix, so +// these fixtures must keep it — inventing shorter URLs hides placeholder-filter +// bugs that would drop 100% of live results. +const REAL_IMAGE_URL = + 'https://cdn-images.dzcdn.net/images/artist/96b688020014a21cb80a0268b90287f5/1000x1000-000000-80-0-0.jpg'; +const EMPTY_HASH_IMAGE_URL = + 'https://cdn-images.dzcdn.net/images/artist//1000x1000-000000-80-0-0.jpg'; +const ZERO_BYTE_HASH_IMAGE_URL = + 'https://cdn-images.dzcdn.net/images/artist/d41d8cd98f00b204e9800998ecf8427e/1000x1000-000000-80-0-0.jpg'; + +test('accepts valid Deezer artists and drops corrupt or placeholder images', () => { + const parsed = parseDeezerArtistPayload({ + data: [ + { + id: 7, + name: 'Artist', + picture_xl: REAL_IMAGE_URL, + link: 'https://www.deezer.com/artist/7', + nb_fan: 120, + nb_album: 4, + }, + { id: 8, name: 'Empty Hash', picture_xl: EMPTY_HASH_IMAGE_URL }, + { id: 11, name: 'Zero Byte Hash', picture_xl: ZERO_BYTE_HASH_IMAGE_URL }, + { id: 9, name: '', picture_xl: 'https://cdn.test/9.jpg' }, + { id: 10, name: 'Unsafe', picture_xl: 'http://cdn.test/10.jpg' }, + ], + }); + assert.deepEqual(parsed, [ + { + provider: 'deezer', + id: '7', + name: 'Artist', + imageUrl: REAL_IMAGE_URL, + linkUrl: 'https://www.deezer.com/artist/7', + fanCount: 120, + albumCount: 4, + }, + ]); + assert.equal(parseDeezerArtistPayload({ nope: [] }), null); +}); + +test('automatic selection only accepts normalized exact names', () => { + const candidates = [ + candidate('1', 'The National Tribute', 9_000), + candidate('2', 'The National', 200), + ]; + assert.equal(pickAutomaticDeezerCandidate('The National', candidates)?.id, '2'); + assert.equal(pickAutomaticDeezerCandidate('National', candidates), null); +}); + +test('same-name matches rank by fans, then stable numeric id', () => { + const candidates = [ + candidate('40', 'Björk', 50), + candidate('7', 'Bjork', 100), + candidate('3', 'BJÖRK', 100), + ]; + assert.equal(pickAutomaticDeezerCandidate('Björk', candidates)?.id, '3'); +}); + +test('empty results are terminal while rate limits remain retryable', async () => { + const originalFetch = globalThis.fetch; + try { + globalThis.fetch = async () => + new Response(JSON.stringify({ data: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + assert.deepEqual(await searchDeezerArtists('Nobody'), { + status: 'success', + candidates: [], + }); + + globalThis.fetch = async () => new Response('', { status: 429 }); + const limited = await searchDeezerArtists('Somebody'); + assert.equal(limited.status, 'transient_error'); + if (limited.status === 'transient_error') { + assert.equal(limited.code, 'rate_limited'); + } + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('an automatic request can be cancelled without becoming a failed lookup', async () => { + const originalFetch = globalThis.fetch; + try { + globalThis.fetch = (_input, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => { + reject(new DOMException('Aborted', 'AbortError')); + }); + }); + const controller = new AbortController(); + const request = searchDeezerArtists('Pause Me', controller.signal); + controller.abort(); + const result = await request; + assert.equal(result.status, 'transient_error'); + if (result.status === 'transient_error') assert.equal(result.code, 'cancelled'); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/src/services/artistImages/deezer.ts b/src/services/artistImages/deezer.ts new file mode 100644 index 0000000..472138e --- /dev/null +++ b/src/services/artistImages/deezer.ts @@ -0,0 +1,183 @@ +import type { + DeezerArtistCandidate, + DeezerSearchResult, +} from '@/types/artistImages'; + +const DEEZER_ARTIST_SEARCH_URL = 'https://api.deezer.com/search/artist'; +const DEFAULT_RETRY_MS = 30 * 60 * 1000; +const RATE_LIMIT_RETRY_MS = 6 * 60 * 60 * 1000; +const REQUEST_TIMEOUT_MS = 8_000; + +export const DEEZER_ARTIST_IMAGES_ENABLED = + process.env.EXPO_PUBLIC_DEEZER_ARTIST_IMAGES_ENABLED !== 'false'; + +export function normalizeArtistImageMatchName(value: string): string { + return value + .normalize('NFKD') + .replace(/\p{M}+/gu, '') + .toLocaleLowerCase('en-US') + .replace(/[^\p{L}\p{N}]+/gu, ' ') + .trim() + .replace(/\s+/g, ' '); +} + +function finiteCount(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 + ? Math.floor(value) + : 0; +} + +function safeHttpsUrl(value: unknown): string | null { + if (typeof value !== 'string') return null; + try { + const parsed = new URL(value); + return parsed.protocol === 'https:' ? parsed.toString() : null; + } catch { + return null; + } +} + +// Deezer serves a grey silhouette when an artist has no photo: the hash path +// segment is empty, all zeroes, or the md5 of zero bytes. Do NOT match on the +// trailing "-000000-80-0-0" transform suffix — every real CDN URL carries it. +const DEEZER_PLACEHOLDER_IMAGE = + /\/artist\/(?:\/|0+\/|d41d8cd98f00b204e9800998ecf8427e\/)/i; + +function isPlaceholderImage(url: string): boolean { + return DEEZER_PLACEHOLDER_IMAGE.test(url); +} + +export function parseDeezerArtistPayload(payload: unknown): DeezerArtistCandidate[] | null { + if (typeof payload !== 'object' || payload === null) return null; + const data = (payload as { data?: unknown }).data; + if (!Array.isArray(data)) return null; + + const candidates: DeezerArtistCandidate[] = []; + for (const raw of data) { + if (typeof raw !== 'object' || raw === null) continue; + const item = raw as Record; + const id = + typeof item.id === 'number' || typeof item.id === 'string' + ? String(item.id).trim() + : ''; + const name = typeof item.name === 'string' ? item.name.trim() : ''; + const imageUrl = + safeHttpsUrl(item.picture_xl) ?? + safeHttpsUrl(item.picture_big) ?? + safeHttpsUrl(item.picture_medium); + if (!id || !name || !imageUrl || isPlaceholderImage(imageUrl)) continue; + + candidates.push({ + provider: 'deezer', + id, + name, + imageUrl, + linkUrl: safeHttpsUrl(item.link), + fanCount: finiteCount(item.nb_fan), + albumCount: finiteCount(item.nb_album), + }); + } + return candidates; +} + +export function pickAutomaticDeezerCandidate( + artistName: string, + candidates: DeezerArtistCandidate[] +): DeezerArtistCandidate | null { + const target = normalizeArtistImageMatchName(artistName); + if (!target) return null; + return ( + candidates + .filter((candidate) => normalizeArtistImageMatchName(candidate.name) === target) + .sort((left, right) => { + if (right.fanCount !== left.fanCount) return right.fanCount - left.fanCount; + const leftNumeric = Number(left.id); + const rightNumeric = Number(right.id); + if (Number.isFinite(leftNumeric) && Number.isFinite(rightNumeric)) { + return leftNumeric - rightNumeric; + } + return left.id.localeCompare(right.id); + })[0] ?? null + ); +} + +export async function searchDeezerArtists( + query: string, + externalSignal?: AbortSignal +): Promise { + if (!DEEZER_ARTIST_IMAGES_ENABLED) { + return { + status: 'transient_error', + code: 'provider', + message: 'Deezer artist images are disabled in this build.', + retryAfterMs: RATE_LIMIT_RETRY_MS, + }; + } + const normalizedQuery = query.trim(); + if (!normalizedQuery) return { status: 'success', candidates: [] }; + + const controller = new AbortController(); + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + controller.abort(); + }, REQUEST_TIMEOUT_MS); + const cancel = () => controller.abort(); + externalSignal?.addEventListener('abort', cancel, { once: true }); + if (externalSignal?.aborted) controller.abort(); + try { + const response = await fetch( + `${DEEZER_ARTIST_SEARCH_URL}?q=${encodeURIComponent(normalizedQuery)}&limit=20`, + { + headers: { Accept: 'application/json' }, + signal: controller.signal, + } + ); + if (response.status === 429) { + return { + status: 'transient_error', + code: 'rate_limited', + message: 'Deezer is temporarily rate limiting searches. Try again later.', + retryAfterMs: RATE_LIMIT_RETRY_MS, + }; + } + if (!response.ok) { + return { + status: 'transient_error', + code: 'provider', + message: 'Deezer is temporarily unavailable.', + retryAfterMs: DEFAULT_RETRY_MS, + }; + } + + const candidates = parseDeezerArtistPayload(await response.json()); + if (candidates === null) { + return { + status: 'transient_error', + code: 'invalid_response', + message: 'Deezer returned an unexpected response.', + retryAfterMs: DEFAULT_RETRY_MS, + }; + } + return { status: 'success', candidates }; + } catch (error) { + const cancelled = + error instanceof Error && + error.name === 'AbortError' && + externalSignal?.aborted === true && + !timedOut; + return { + status: 'transient_error', + code: cancelled ? 'cancelled' : timedOut ? 'timeout' : 'offline', + message: cancelled + ? 'The Deezer search was paused.' + : timedOut + ? 'The Deezer search timed out. Try again.' + : 'Connect to the internet and try the Deezer search again.', + retryAfterMs: cancelled ? 0 : DEFAULT_RETRY_MS, + }; + } finally { + clearTimeout(timeout); + externalSignal?.removeEventListener('abort', cancel); + } +} diff --git a/src/stores/artistImageStore.ts b/src/stores/artistImageStore.ts new file mode 100644 index 0000000..541452a --- /dev/null +++ b/src/stores/artistImageStore.ts @@ -0,0 +1,51 @@ +import { create } from 'zustand'; +import { AstraLibraryData } from '../../modules/astra-library-scanner'; +import { useSettingsStore } from './settingsStore'; + +interface ArtistImageState { + /** True while a sweep is draining the queue. */ + running: boolean; + /** Artists resolved so far in the current sweep — one per provider request. */ + processed: number; + /** Artists queued when the sweep started. 0 means "not counted yet". */ + total: number; + /** Artists with no portrait from any source, refreshed when a sweep settles. */ + missing: number; + beginSweep: (total: number) => void; + advanceSweep: (by?: number) => void; + endSweep: () => void; + refreshMissing: () => Promise; +} + +/** + * Observable progress for the artist-image sweep. The coordinator itself is a + * plain module (it runs without any React tree mounted), so it pushes into this + * store rather than owning the state — Settings just subscribes. + */ +export const useArtistImageStore = create((set, get) => ({ + running: false, + processed: 0, + total: 0, + missing: 0, + + beginSweep: (total) => set({ running: true, processed: 0, total }), + + advanceSweep: (by = 1) => set((state) => ({ processed: state.processed + by })), + + endSweep: () => { + set({ running: false, processed: 0, total: 0 }); + void get().refreshMissing(); + }, + + refreshMissing: async () => { + try { + const stats = await AstraLibraryData.getArtistImageStats( + useSettingsStore.getState().artistGroupingMode, + Date.now() + ); + set({ missing: stats.missing }); + } catch { + // A stale count is not worth surfacing an error for. + } + }, +})); diff --git a/src/stores/libraryStore.ts b/src/stores/libraryStore.ts index 5216a5d..d011ac0 100644 --- a/src/stores/libraryStore.ts +++ b/src/stores/libraryStore.ts @@ -17,6 +17,7 @@ import { type ScanResult, } from '@/library/scanner'; import { endScanService, reportScanProgress } from '@/library/scanService'; +import { requeueMissingArtistImages } from '@/library/artistImageLookup'; import { ALBUM_SORT_LABELS, type AlbumSort } from '@/lib/albumSort'; import { ARTIST_SORT_LABELS, type ArtistSort } from '@/lib/artistSort'; import { TRACK_SORT_LABELS, type TrackSort } from '@/lib/trackSort'; @@ -191,6 +192,7 @@ export const useLibraryStore = create((set, get) => { artists: 0, }; let anchorGeneration = 0; + let artistImageRefreshTimer: ReturnType | null = null; // Re-entrancy guards, per list and per direction, so a backward refill, a forward // page and a different view's load never block one another — the single shared flag @@ -391,6 +393,10 @@ export const useLibraryStore = create((set, get) => { } finally { try { await get().refresh(); + // A scan is the user asking Astra to look at their library again, so it + // also re-opens artist-image lookups that previously found no match. + // New artists queue on their own; these would never retry otherwise. + await requeueMissingArtistImages(); } finally { if (activeScanCancellation === cancellation) activeScanCancellation = null; set({ @@ -490,6 +496,13 @@ export const useLibraryStore = create((set, get) => { set({ sectionAnchors: [] }); void get().refresh(); }); + AstraLibraryData.addListener('onArtistImagesChanged', () => { + if (artistImageRefreshTimer) clearTimeout(artistImageRefreshTimer); + artistImageRefreshTimer = setTimeout(() => { + artistImageRefreshTimer = null; + void get().refresh(); + }, 300); + }); useSettingsStore.subscribe((next, previous) => { if (next.artistGroupingMode !== previous.artistGroupingMode) { anchorGeneration += 1; diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts index 5b24542..5f4df1f 100644 --- a/src/stores/settingsStore.ts +++ b/src/stores/settingsStore.ts @@ -14,6 +14,7 @@ import { resumeListeningHistoryTracking, } from '@/audio/listeningHistoryTracker'; import { notifyListeningHistoryChanged } from '@/listeningStats/events'; +import type { ArtistImageAutoPolicy } from '@/types/artistImages'; /** * Persisted app preferences. SQLite (settings table) is the source of truth — this @@ -29,6 +30,8 @@ const LYRICS_VISIBLE_KEY = 'lyrics_visible'; const NOW_PLAYING_COMPANION_KEY = 'now_playing_companion'; const HOME_GREETING_TEXT_MODE_KEY = 'home_greeting_text_mode'; const LISTENING_HISTORY_ENABLED_KEY = 'listening_history_enabled'; +const ARTIST_IMAGE_AUTO_POLICY_KEY = 'artist_image_auto_policy'; +const ARTIST_IMAGE_DISCLOSURE_KEY = 'artist_image_disclosure_seen'; /** Which visualizer the now-playing scope stage shows. */ export type ScopeMode = 'spectrum' | 'scope'; @@ -56,6 +59,10 @@ function parseBoolean(value: string | null): boolean { return value === 'true'; } +function parseArtistImageAutoPolicy(value: string | null): ArtistImageAutoPolicy { + return value === 'off' || value === 'any' ? value : 'wifi'; +} + interface SettingsStore { artistGroupingMode: ArtistGroupingMode; /** Show 1-track albums in the Albums view (desktop parity default: hidden). */ @@ -68,6 +75,8 @@ interface SettingsStore { nowPlayingCompanion: NowPlayingCompanion; homeGreetingTextMode: HomeGreetingTextMode; listeningHistoryEnabled: boolean; + artistImageAutoPolicy: ArtistImageAutoPolicy; + artistImageDisclosureSeen: boolean; loaded: boolean; load: () => Promise; setArtistGroupingMode: (mode: ArtistGroupingMode) => Promise; @@ -79,6 +88,8 @@ interface SettingsStore { setNowPlayingCompanion: (companion: NowPlayingCompanion) => Promise; setHomeGreetingTextMode: (mode: HomeGreetingTextMode) => Promise; setListeningHistoryEnabled: (enabled: boolean) => Promise; + setArtistImageAutoPolicy: (policy: ArtistImageAutoPolicy) => Promise; + acknowledgeArtistImageDisclosure: () => Promise; } export const useSettingsStore = create((set, get) => ({ @@ -91,6 +102,8 @@ export const useSettingsStore = create((set, get) => ({ nowPlayingCompanion: 'queue', homeGreetingTextMode: 'messages', listeningHistoryEnabled: true, + artistImageAutoPolicy: 'wifi', + artistImageDisclosureSeen: false, loaded: false, load: async () => { @@ -106,6 +119,8 @@ export const useSettingsStore = create((set, get) => ({ NOW_PLAYING_COMPANION_KEY, HOME_GREETING_TEXT_MODE_KEY, LISTENING_HISTORY_ENABLED_KEY, + ARTIST_IMAGE_AUTO_POLICY_KEY, + ARTIST_IMAGE_DISCLOSURE_KEY, ]); const grouping = values[ARTIST_GROUPING_KEY] ?? null; const includeSingles = values[INCLUDE_SINGLES_KEY] ?? null; @@ -116,6 +131,10 @@ export const useSettingsStore = create((set, get) => ({ const nowPlayingCompanion = values[NOW_PLAYING_COMPANION_KEY] ?? null; const homeGreetingTextMode = values[HOME_GREETING_TEXT_MODE_KEY] ?? null; const listeningHistoryEnabled = values[LISTENING_HISTORY_ENABLED_KEY] !== '0'; + const artistImageAutoPolicy = parseArtistImageAutoPolicy( + values[ARTIST_IMAGE_AUTO_POLICY_KEY] ?? null + ); + const artistImageDisclosureSeen = values[ARTIST_IMAGE_DISCLOSURE_KEY] === '1'; set({ artistGroupingMode: parseGroupingMode(grouping), includeSingles: parseBoolean(includeSingles), @@ -126,6 +145,8 @@ export const useSettingsStore = create((set, get) => ({ nowPlayingCompanion: parseNowPlayingCompanion(nowPlayingCompanion), homeGreetingTextMode: parseHomeGreetingTextMode(homeGreetingTextMode), listeningHistoryEnabled, + artistImageAutoPolicy, + artistImageDisclosureSeen, loaded: true, }); }, @@ -194,4 +215,22 @@ export const useSettingsStore = create((set, get) => ({ throw error; } }, + + setArtistImageAutoPolicy: async (policy) => { + const previous = get().artistImageAutoPolicy; + if (previous === policy) return; + set({ artistImageAutoPolicy: policy }); + try { + await AstraLibraryData.setSettings({ [ARTIST_IMAGE_AUTO_POLICY_KEY]: policy }); + } catch (error) { + set({ artistImageAutoPolicy: previous }); + throw error; + } + }, + + acknowledgeArtistImageDisclosure: async () => { + if (get().artistImageDisclosureSeen) return; + await AstraLibraryData.setSettings({ [ARTIST_IMAGE_DISCLOSURE_KEY]: '1' }); + set({ artistImageDisclosureSeen: true }); + }, })); diff --git a/src/types/artistImages.ts b/src/types/artistImages.ts new file mode 100644 index 0000000..5bd97ef --- /dev/null +++ b/src/types/artistImages.ts @@ -0,0 +1,49 @@ +export type ArtistImageAutoPolicy = 'off' | 'wifi' | 'any'; +export type ArtistGroupingMode = 'astra' | 'fileTags'; + +export interface DeezerArtistCandidate { + provider: 'deezer'; + id: string; + name: string; + imageUrl: string; + linkUrl: string | null; + fanCount: number; + albumCount: number; +} + +export interface ArtistImageLookupTarget { + groupingMode: ArtistGroupingMode; + artistKey: string; + artistName: string; + retryCount?: number; +} + +export interface PersistedArtistImageState { + groupingMode: ArtistGroupingMode; + artistKey: string; + artistName?: string; + manualImageHash: string | null; + automaticImageHash: string | null; + automaticProvider: 'deezer' | null; + automaticSourceId: string | null; + lookupStatus: 'never' | 'found' | 'not_found' | 'transient_error'; + retryCount: number; + lastAttemptAt: number | null; + nextRetryAt: number | null; + updatedAt: number | null; +} + +export type DeezerSearchResult = + | { status: 'success'; candidates: DeezerArtistCandidate[] } + | { + status: 'transient_error'; + message: string; + retryAfterMs: number; + code: + | 'offline' + | 'timeout' + | 'cancelled' + | 'rate_limited' + | 'provider' + | 'invalid_response'; + }; diff --git a/src/types/library.ts b/src/types/library.ts index ca1ebc9..a6adf0a 100644 --- a/src/types/library.ts +++ b/src/types/library.ts @@ -88,6 +88,7 @@ export interface Artist { /** Tracks where this artist is the resolved primary browse artist. */ primary_track_count: number; artwork_hash: string | null; + artwork_source: 'manual' | 'deezer' | 'track' | null; album_count: number; /** Primary hash first, then one distinct cover per further album (max 4) — grid mosaic. */ artwork_hashes: string[];