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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package com.lostf1sh.pixelplayeross.data.database

import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class ArtistFolderDirectoriesDaoTest {
private lateinit var database: PixelPlayerDatabase
private lateinit var musicDao: MusicDao

@Before
fun createDatabase() {
database = Room.inMemoryDatabaseBuilder(
ApplicationProvider.getApplicationContext<Context>(),
PixelPlayerDatabase::class.java
)
.addCallback(PixelPlayerDatabase.createRuntimeArtifactsCallback())
.allowMainThreadQueries()
.build()
musicDao = database.musicDao()
}

@After
fun closeDatabase() {
database.close()
}

@Test
fun returnsDistinctLocalDirectoriesForPrimaryAlbumAndGuestArtists() = runTest {
insertArtistRolesFixture()

assertEquals(
listOf("/music/Album A", "/music/Album B", "/music/Album C"),
musicDao.getLocalArtistDirectories(ARTIST_ID, limit = 64)
)
}

@Test
fun appliesLimitAfterDeduplicationAndExcludingRemoteAndEmptyPaths() = runTest {
insertArtistRolesFixture()

assertEquals(
listOf("/music/Album A", "/music/Album B"),
musicDao.getLocalArtistDirectories(ARTIST_ID, limit = 2)
)
}

private suspend fun insertArtistRolesFixture() {
val songs = listOf(
// The same folder is reachable through every UNION branch and multiple tracks.
song(1L, "/music/Album A", artistId = ARTIST_ID, albumArtistId = ARTIST_ID),
song(2L, "/music/Album A", artistId = ARTIST_ID),
song(3L, "/music/Album B", albumArtistId = ARTIST_ID),
song(4L, "/music/Album C"),
song(5L, "/aaa-unrelated"),
song(6L, "", artistId = ARTIST_ID, albumArtistId = ARTIST_ID),
// Plausible cached paths on cloud tracks must be excluded in each UNION branch.
song(-1L, "/aaa-cloud-primary", artistId = ARTIST_ID, sourceType = SourceType.NAVIDROME),
song(-2L, "/aaa-cloud-album", albumArtistId = ARTIST_ID, sourceType = SourceType.JELLYFIN),
song(-3L, "/aaa-cloud-guest", sourceType = SourceType.NAVIDROME)
)
musicDao.insertMusicData(
songs = songs,
albums = listOf(
AlbumEntity(
id = ALBUM_ID,
title = "Fixture Album",
artistName = "Other Artist",
artistId = OTHER_ARTIST_ID,
albumArtUriString = null,
songCount = songs.size,
dateAdded = 0L,
year = 2026
)
),
artists = listOf(
ArtistEntity(id = ARTIST_ID, name = "Artist", trackCount = 0),
ArtistEntity(id = OTHER_ARTIST_ID, name = "Other Artist", trackCount = 0)
)
)
musicDao.insertSongArtistCrossRefs(
listOf(
SongArtistCrossRef(songId = 1L, artistId = ARTIST_ID, isPrimary = true),
SongArtistCrossRef(songId = 4L, artistId = ARTIST_ID, isPrimary = false),
SongArtistCrossRef(songId = 5L, artistId = OTHER_ARTIST_ID, isPrimary = true),
SongArtistCrossRef(songId = 6L, artistId = ARTIST_ID, isPrimary = true),
SongArtistCrossRef(songId = -3L, artistId = ARTIST_ID, isPrimary = false)
)
)
}

private fun song(
id: Long,
directory: String,
artistId: Long = OTHER_ARTIST_ID,
albumArtistId: Long = OTHER_ARTIST_ID,
sourceType: Int = SourceType.LOCAL
): SongEntity = SongEntity(
id = id,
title = "Track $id",
artistName = if (artistId == ARTIST_ID) "Artist" else "Other Artist",
artistId = artistId,
albumArtistId = albumArtistId,
albumName = "Fixture Album",
albumId = ALBUM_ID,
contentUriString = when (sourceType) {
SourceType.NAVIDROME -> "navidrome://track-$id"
SourceType.JELLYFIN -> "jellyfin://track-$id"
else -> "content://media/external/audio/media/$id"
},
albumArtUriString = null,
duration = 180_000L,
genre = null,
filePath = "$directory/track-$id.flac",
parentDirectoryPath = directory,
sourceType = sourceType
)

private companion object {
const val ARTIST_ID = 42L
const val OTHER_ARTIST_ID = 99L
const val ALBUM_ID = 201L
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@ class SyncWorkerTest {
musicDao = dao,
userPreferencesRepository = createTestPreferencesRepository(),
lyricsRepository = mockk(relaxed = true),
cloudSyncCoordinator = mockk(relaxed = true)
cloudSyncCoordinator = mockk(relaxed = true),
artistImageRepository = mockk(relaxed = true)
)
} else {
null
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@
android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<!-- Optional: only requested when the user enables folder cover images, which reads
cover.jpg/folder.jpg next to audio files. READ_MEDIA_AUDIO does not cover image files. -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<!-- Request alongside READ_MEDIA_IMAGES to distinguish partial access on Android 14+. -->
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import com.lostf1sh.pixelplayeross.data.repository.ArtistImageRepository
import com.lostf1sh.pixelplayeross.presentation.viewmodel.LibraryStateHolder
import com.lostf1sh.pixelplayeross.presentation.viewmodel.ThemeStateHolder
import com.lostf1sh.pixelplayeross.utils.AlbumArtCacheManager
import com.lostf1sh.pixelplayeross.utils.AlbumArtUtils
import com.lostf1sh.pixelplayeross.utils.FolderArtworkSettingsCoordinator
import com.lostf1sh.pixelplayeross.utils.AppLocaleManager
import com.lostf1sh.pixelplayeross.utils.CrashHandler
import com.lostf1sh.pixelplayeross.utils.MediaMetadataRetrieverPool
Expand Down Expand Up @@ -63,6 +63,9 @@ class PixelPlayerApplication : Application(), ImageLoaderFactory, Configuration.
@Inject
lateinit var userPreferencesRepository: dagger.Lazy<UserPreferencesRepository>

@Inject
lateinit var folderArtworkSettingsCoordinator: dagger.Lazy<FolderArtworkSettingsCoordinator>

@Inject
lateinit var syncManager: dagger.Lazy<com.lostf1sh.pixelplayeross.data.worker.SyncManager>

Expand All @@ -85,6 +88,11 @@ class PixelPlayerApplication : Application(), ImageLoaderFactory, Configuration.
advancedPerformanceDiagnosticsController.get().onAppForeground()
}

override fun onResume(owner: LifecycleOwner) {
// Image access can change while system settings is open, without restarting us.
startupScope.launch { folderArtworkSettingsCoordinator.get().reconcile() }
}

override fun onStop(owner: LifecycleOwner) {
advancedPerformanceDiagnosticsController.get().onAppBackground()
}
Expand Down Expand Up @@ -126,14 +134,22 @@ class PixelPlayerApplication : Application(), ImageLoaderFactory, Configuration.
advancedPerformanceDiagnosticsController.get().start(startupScope)

startupScope.launch {
AlbumArtUtils.migrateLegacyCacheLocation(this@PixelPlayerApplication)
val savedLimit = runCatching {
userPreferencesRepository.get().albumArtCacheLimitMbFlow.first()
}.getOrNull()
if (savedLimit != null) {
AlbumArtCacheManager.configuredCacheLimitMb = savedLimit.toLong()
}
}

// Collected rather than read once: the preference can also change underneath us, most
// notably when a backup restore writes it straight into DataStore. A stale mirror would
// leave the toggle reporting one thing while artwork resolution did another.
startupScope.launch {
userPreferencesRepository.get().useFolderAlbumArtFlow.collect {
folderArtworkSettingsCoordinator.get().reconcile()
}
}
}

override fun newImageLoader(): ImageLoader {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,11 @@ interface AlbumArtThemeDao {

@Query("DELETE FROM album_art_themes WHERE albumArtUriString IN (:uriStrings)")
suspend fun deleteThemesByUris(uriStrings: List<String>)

@Query("""
DELETE FROM album_art_themes
WHERE albumArtUriString GLOB 'pixelplayer_local_art://*'
OR albumArtUriString GLOB 'pixelplay_local_art://*'
""")
suspend fun deleteLocalArtworkThemes()
}
Original file line number Diff line number Diff line change
Expand Up @@ -1572,6 +1572,25 @@ interface MusicDao {
@Query("SELECT custom_image_uri FROM artists WHERE id = :artistId")
suspend fun getArtistCustomImage(artistId: Long): String?

/**
* Resolve indexed artist IDs first, then look up their paths by primary key. CROSS JOIN
* keeps SQLite from scanning every local song via source_type for each artist in a list.
*/
@Query("""
SELECT DISTINCT songs.parent_directory_path FROM (
SELECT id AS song_id FROM songs WHERE artist_id = :artistId
UNION
SELECT id AS song_id FROM songs WHERE album_artist_id = :artistId
UNION
SELECT song_id FROM song_artist_cross_ref WHERE artist_id = :artistId
) AS artist_songs
CROSS JOIN songs ON songs.id = artist_songs.song_id
WHERE songs.source_type = 0 AND songs.parent_directory_path != ''
ORDER BY songs.parent_directory_path
LIMIT :limit
""")
suspend fun getLocalArtistDirectories(artistId: Long, limit: Int): List<String>

@Query("""
SELECT * FROM songs
WHERE (:applyDirectoryFilter = 0 OR id < 0 OR parent_directory_path IN (:allowedParentDirs))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,20 @@ import javax.inject.Singleton
class ImageCacheManager @Inject constructor(
@ApplicationContext private val context: Context
) {
/**
* Drops every decoded and on-disk image Coil holds.
*
* Needed when artwork changes for the whole library at once and there is no list of affected
* URIs to hand to [invalidateCoverArtCaches] — the per-URI path can only guess at Coil's
* size-suffixed memory keys, so it cannot be trusted for a library-wide change.
*/
@OptIn(ExperimentalCoilApi::class)
fun clearAllCoverArtCaches() {
val imageLoader = context.imageLoader
imageLoader.memoryCache?.clear()
imageLoader.diskCache?.clear()
}

@OptIn(ExperimentalCoilApi::class)
fun invalidateCoverArtCaches(vararg uriStrings: String?) {
val imageLoader = context.imageLoader
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
Expand Down Expand Up @@ -89,6 +90,9 @@ constructor(

private val backupExcludedKeyNames = setOf(
PreferencesKeys.INITIAL_SETUP_DONE.name,
// Describes this device's artwork cache, not a user choice — restoring another
// device's value would misreport what the local cache was built from.
PreferencesKeys.FOLDER_ALBUM_ART_CACHE_STATE.name,
"listenbrainz_token",
"lastfm_session_key"
)
Expand Down Expand Up @@ -211,6 +215,8 @@ constructor(

val ALBUM_ART_QUALITY = stringPreferencesKey("album_art_quality")
val ALBUM_ART_CACHE_LIMIT_MB = intPreferencesKey("album_art_cache_limit_mb")
val USE_FOLDER_ALBUM_ART = booleanPreferencesKey("use_folder_album_art")
val FOLDER_ALBUM_ART_CACHE_STATE = booleanPreferencesKey("folder_album_art_cache_state")
val TAP_BACKGROUND_CLOSES_PLAYER = booleanPreferencesKey("tap_background_closes_player")
val HAPTICS_ENABLED = booleanPreferencesKey("haptics_enabled")
val ADVANCED_PERFORMANCE_DIAGNOSTICS_ENABLED =
Expand Down Expand Up @@ -1228,6 +1234,21 @@ constructor(
/** Default word-based delimiters (matched case-insensitively with whitespace boundaries) */
val DEFAULT_ARTIST_WORD_DELIMITERS = listOf("featuring", "feat.", "feat", "ft.", "ft", "vs.", "vs", "versus", "with", "prod.", "prod")
const val DEFAULT_ALBUM_ART_CACHE_LIMIT_MB = 200

/**
* Synchronous read of the folder-cover-art opt-in.
*
* [com.lostf1sh.pixelplayeross.utils.AlbumArtUtils] resolves artwork from a Coil fetcher
* and a ContentProvider, both off the main thread, and it cannot proceed without knowing
* this value: resolving under a wrong assumption persists a cache entry or a "no art"
* marker that later reads then trust. The mirror is normally pushed in long before any
* artwork is requested, so this only runs if a very early request wins that race.
*/
fun readUseFolderAlbumArtBlocking(context: Context): Boolean = runBlocking {
runCatching {
context.applicationContext.dataStore.data.first()[PreferencesKeys.USE_FOLDER_ALBUM_ART]
}.getOrNull() ?: false
}
}

val navBarCornerRadiusFlow: Flow<Int> =
Expand Down Expand Up @@ -1600,6 +1621,43 @@ constructor(
}
}

/**
* Whether a cover image sitting next to the audio file (cover.jpg, folder.jpg, …) should be
* used as album art in preference to embedded artwork. Off by default: folder images are only
* trusted when the user explicitly opts in, because generic directories can hold unrelated
* pictures. See [com.lostf1sh.pixelplayeross.utils.AlbumArtUtils.findExternalAlbumArtFile].
*/
val useFolderAlbumArtFlow: Flow<Boolean> =
dataStore.data.map { preferences ->
preferences[PreferencesKeys.USE_FOLDER_ALBUM_ART] ?: false
}.distinctUntilChanged()

suspend fun setUseFolderAlbumArt(enabled: Boolean) {
dataStore.edit { preferences ->
preferences[PreferencesKeys.USE_FOLDER_ALBUM_ART] = enabled
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}

/**
* The effective folder-cover state the artwork cache was last built under, or null if it has
* never been recorded.
*
* Persisted because the cache it describes is: covers live in `filesDir` and outlive the
* process, so an in-memory flag could not tell whether entries were produced with or without
* folder access. Effective means the preference *and* the image permission — losing the
* permission silently changes what gets cached just as much as switching the setting off.
*/
val folderAlbumArtCacheStateFlow: Flow<Boolean?> =
dataStore.data.map { preferences ->
preferences[PreferencesKeys.FOLDER_ALBUM_ART_CACHE_STATE]
}

suspend fun setFolderAlbumArtCacheState(enabled: Boolean) {
dataStore.edit { preferences ->
preferences[PreferencesKeys.FOLDER_ALBUM_ART_CACHE_STATE] = enabled
}
}

/**
* Whether tapping the background area of the player sheet closes it.
* Default is false to avoid accidental dismissals while interacting with the full player.
Expand Down
Loading