Skip to content
Open
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

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ data class JellyfinSongEntity(
val title: String,
val artist: String,
@ColumnInfo(name = "artist_id") val artistId: String?,
@ColumnInfo(name = "album_artist") val albumArtist: String? = null,
val album: String,
@ColumnInfo(name = "album_id") val albumId: String?,
val duration: Long,
Expand Down Expand Up @@ -66,6 +67,7 @@ fun JellyfinSong.toEntity(playlistId: String): JellyfinSongEntity {
title = title,
artist = artist,
artistId = artistId,
albumArtist = albumArtist,
album = album,
albumId = albumId,
duration = duration,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package com.lostf1sh.pixelplayeross.data.database

import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase

/**
* Returns true if [table] already has a column named [column].
*
* Platform Auto Backup can restore a database that reports the right schema version but whose
* columns have drifted, so migrations guard each `ALTER` with this check instead of assuming a
* bare `ADD COLUMN` is safe (see the migration notes in CLAUDE.md / CONTRIBUTING).
*/
private fun SupportSQLiteDatabase.hasColumn(table: String, column: String): Boolean {
query("PRAGMA table_info(`$table`)").use { cursor ->
val nameIndex = cursor.getColumnIndex("name")
if (nameIndex < 0) return false
while (cursor.moveToNext()) {
if (cursor.getString(nameIndex) == column) return true
}
}
return false
}

private fun SupportSQLiteDatabase.addColumnIfMissing(table: String, column: String, ddl: String) {
if (!hasColumn(table, column)) {
execSQL("ALTER TABLE `$table` ADD COLUMN $ddl")
}
}

/**
* v1 -> v2: album-artist support for the unified library (issue #8).
*
* - `songs.album_artist_id`: id of the *effective* album artist (the song's `album_artist` when
* present, otherwise its primary track artist). Source-independent, so the "Group by Album
* Artist" Artists tab can collapse on it at runtime without forcing a re-sync.
* - `navidrome_songs.album_artist` / `jellyfin_songs.album_artist`: carry the server's
* album-artist tag through the cloud cache so the unified projection can populate the above.
*
* Additive and idempotent — each column is added only when missing. `album_artist_id` is then
* backfilled to the existing primary artist so the collapsed Artists tab is populated before the
* next library sync recomputes precise values (e.g. collapsing compilations under one artist).
*/
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.addColumnIfMissing("songs", "album_artist_id", "`album_artist_id` INTEGER NOT NULL DEFAULT 0")
db.addColumnIfMissing("navidrome_songs", "album_artist", "`album_artist` TEXT")
db.addColumnIfMissing("jellyfin_songs", "album_artist", "`album_artist` TEXT")

// Seed from the existing primary artist so the collapsed tab is non-empty immediately.
db.execSQL("UPDATE songs SET album_artist_id = artist_id WHERE album_artist_id = 0")

db.execSQL("CREATE INDEX IF NOT EXISTS `index_songs_album_artist_id` ON `songs` (`album_artist_id`)")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ private const val SONG_DETAIL_PROJECTION = """
songs.artist_name AS artist_name,
songs.artist_id AS artist_id,
songs.album_artist AS album_artist,
songs.album_artist_id AS album_artist_id,
songs.album_name AS album_name,
songs.album_id AS album_id,
songs.content_uri_string AS content_uri_string,
Expand Down Expand Up @@ -77,7 +78,7 @@ private const val SONG_DETAIL_PROJECTION = """
// Projection for list queries: excludes lyrics to prevent CursorWindow overflow (2MB limit)
// when loading large libraries. Lyrics are only needed for the Now Playing screen (getSongById).
private const val SONG_LIST_PROJECTION = """
id, title, artist_name, artist_id, album_artist, album_name, album_id,
id, title, artist_name, artist_id, album_artist, album_artist_id, album_name, album_id,
content_uri_string, album_art_uri_string, duration, genre, file_path,
parent_directory_path, is_favorite, NULL AS lyrics, track_number, disc_number,
year, date_added, mime_type, bitrate, sample_rate, artists_json, source_type,
Expand Down Expand Up @@ -1324,6 +1325,46 @@ interface MusicDao {
sortOrder: String
): PagingSource<Int, ArtistEntity>

/**
* Album-artist variant of [getArtistsPaginated], used when "Group by Album Artist" is on.
* Collapses the Artists tab onto each song's effective album artist (songs.album_artist_id)
* instead of every track-level artist, so featured/compilation artists stop cluttering the
* list. Counts are per-album-artist; sorting and source/directory filtering mirror the
* track-artist query so toggling the preference only swaps which query backs the tab.
*/
@Query("""
SELECT artists.id, artists.name, artists.image_url, artists.custom_image_uri,
COUNT(DISTINCT songs.id) AS track_count
FROM songs
INNER JOIN artists ON artists.id = songs.album_artist_id
WHERE (:applyDirectoryFilter = 0 OR songs.id < 0 OR songs.parent_directory_path IN (:allowedParentDirs))
AND (
:filterMode = 0
OR (
:filterMode = 1
AND songs.source_type = 0
)
OR (
:filterMode = 2
AND songs.source_type != 0
)
)
GROUP BY artists.id
ORDER BY
CASE WHEN :sortOrder = 'artist_name_az' THEN artists.name END COLLATE NOCASE ASC,
CASE WHEN :sortOrder = 'artist_name_za' THEN artists.name END COLLATE NOCASE DESC,
CASE WHEN :sortOrder = 'artist_num_songs_desc' THEN track_count END DESC,
CASE WHEN :sortOrder = 'artist_num_songs_asc' THEN track_count END ASC,
artists.name COLLATE NOCASE ASC,
artists.id ASC
""")
fun getArtistsPaginatedByAlbumArtist(
allowedParentDirs: List<String>,
applyDirectoryFilter: Boolean,
filterMode: Int,
sortOrder: String
): PagingSource<Int, ArtistEntity>

@Query("""
SELECT artists.id, artists.name, artists.image_url, artists.custom_image_uri,
COUNT(DISTINCT songs.id) AS track_count
Expand Down Expand Up @@ -1498,15 +1539,18 @@ interface MusicDao {
suspend fun deleteOrphanedAlbums()

/**
* An artist is only orphaned when nothing references it: neither the cross-ref table
* nor songs.artist_id. The songs.artist_id check is load-bearing — the songs FK is
* declared ON DELETE SET NULL but the column is NOT NULL, so deleting an artist that
* a song still points at would abort with a constraint error instead of nulling.
* An artist is only orphaned when nothing references it: not the cross-ref table, not
* songs.artist_id, and not songs.album_artist_id. The songs.artist_id check is load-bearing
* — the songs FK is declared ON DELETE SET NULL but the column is NOT NULL, so deleting an
* artist a song still points at would abort with a constraint error instead of nulling. The
* album_artist_id check keeps album-artist-only rows (e.g. "Various Artists", which never
* appear as a track artist and so have no cross-ref) alive for the "Group by Album Artist" tab.
*/
@Query("""
DELETE FROM artists
WHERE NOT EXISTS (SELECT 1 FROM song_artist_cross_ref WHERE song_artist_cross_ref.artist_id = artists.id)
AND NOT EXISTS (SELECT 1 FROM songs WHERE songs.artist_id = artists.id)
AND NOT EXISTS (SELECT 1 FROM songs WHERE songs.album_artist_id = artists.id)
""")
suspend fun deleteOrphanedArtists()

Expand Down Expand Up @@ -1681,6 +1725,19 @@ interface MusicDao {
""")
fun getSongsForArtist(artistId: Long): Flow<List<SongEntity>>

/**
* Album-artist variant of [getSongsForArtist]: every song whose effective album artist is
* [artistId]. Used by the artist detail screen when "Group by Album Artist" is on so the
* detail view stays consistent with the collapsed Artists tab (tapping "Various Artists"
* shows the compilation tracks rather than an empty screen).
*/
@Query("""
SELECT * FROM songs
WHERE album_artist_id = :artistId
ORDER BY title ASC
""")
fun getSongsForArtistByAlbumArtist(artistId: Long): Flow<List<SongEntity>>

/**
* Get all songs for a specific artist (one-shot).
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ data class NavidromeSongEntity(
val title: String,
val artist: String,
@ColumnInfo(name = "artist_id") val artistId: String?,
@ColumnInfo(name = "album_artist") val albumArtist: String? = null,
val album: String,
@ColumnInfo(name = "album_id") val albumId: String?,
@ColumnInfo(name = "cover_art_id") val coverArtId: String?,
Expand Down Expand Up @@ -98,6 +99,7 @@ fun NavidromeSong.toEntity(playlistId: String): NavidromeSongEntity {
title = title,
artist = artist,
artistId = artistId,
albumArtist = albumArtist,
album = album,
albumId = albumId,
coverArtId = coverArt,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
JellyfinSongEntity::class,
JellyfinPlaylistEntity::class
],
version = 1,
version = 2,
exportSchema = true
)
abstract class PixelPlayerDatabase : RoomDatabase() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ object SourceType {
Index(value = ["date_added"], unique = false),
Index(value = ["duration"], unique = false),
Index(value = ["source_type"], unique = false),
Index(value = ["album_artist_id"], unique = false),
Index(value = ["parent_directory_path", "source_type", "album_id"], unique = false),
Index(value = ["parent_directory_path", "source_type", "id"], unique = false)
],
Expand All @@ -67,6 +68,10 @@ data class SongEntity(
@ColumnInfo(name = "artist_name") val artistName: String, // Display string (combined or primary)
@ColumnInfo(name = "artist_id") val artistId: Long, // Primary artist ID for backward compatibility
@ColumnInfo(name = "album_artist") val albumArtist: String? = null, // Album artist from metadata
// Id of the *effective* album artist (album_artist when present, else the primary track
// artist). Source-independent and computed at sync time so the "Group by Album Artist"
// Artists tab can collapse on it at runtime without a re-sync. 0 = unresolved.
@ColumnInfo(name = "album_artist_id", defaultValue = "0") val albumArtistId: Long = 0L,
@ColumnInfo(name = "album_name") val albumName: String,
@ColumnInfo(name = "album_id") val albumId: Long, // index = true removed
@ColumnInfo(name = "content_uri_string") val contentUriString: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import com.lostf1sh.pixelplayeross.data.model.Song
import com.lostf1sh.pixelplayeross.data.network.jellyfin.JellyfinApiService
import com.lostf1sh.pixelplayeross.data.network.jellyfin.JellyfinResponseParser
import com.lostf1sh.pixelplayeross.data.preferences.PlaylistPreferencesRepository
import com.lostf1sh.pixelplayeross.data.preferences.UserPreferencesRepository
import com.lostf1sh.pixelplayeross.data.stream.BulkSyncResult
import com.lostf1sh.pixelplayeross.data.stream.CloudMusicUtils
import dagger.hilt.android.qualifiers.ApplicationContext
Expand All @@ -45,6 +46,7 @@ class JellyfinRepository @Inject constructor(
private val dao: JellyfinDao,
private val musicDao: MusicDao,
private val playlistPreferencesRepository: PlaylistPreferencesRepository,
private val userPreferencesRepository: UserPreferencesRepository,
@ApplicationContext private val context: Context
) {
private companion object {
Expand Down Expand Up @@ -496,6 +498,10 @@ class JellyfinRepository @Inject constructor(
return
}

// When on, "Group by Album Artist" makes the album's display artist the album artist;
// either way the effective album artist is captured on the song for the Artists tab.
val groupByAlbumArtist = userPreferencesRepository.groupByAlbumArtistFlow.first()

val songs = ArrayList<SongEntity>(jellyfinSongs.size)
val artists = LinkedHashMap<Long, ArtistEntity>()
val albums = LinkedHashMap<Long, AlbumEntity>()
Expand All @@ -507,6 +513,25 @@ class JellyfinRepository @Inject constructor(
val primaryArtistName = artistNames.firstOrNull() ?: "Unknown Artist"
val primaryArtistId = toUnifiedArtistId(primaryArtistName)

// Effective album artist (Jellyfin AlbumArtist, else primary track artist), registered
// as a real artist row so songs.album_artist_id can join to it.
val effectiveAlbumArtistName = jellyfinSong.albumArtist
?.trim()
?.takeIf { it.isNotBlank() }
?: primaryArtistName
val albumArtistId = toUnifiedArtistId(effectiveAlbumArtistName)
artists.putIfAbsent(
albumArtistId,
ArtistEntity(
id = albumArtistId,
name = effectiveAlbumArtistName,
trackCount = 0,
imageUrl = null
)
)
val albumDisplayArtistName = if (groupByAlbumArtist) effectiveAlbumArtistName else primaryArtistName
val albumDisplayArtistId = if (groupByAlbumArtist) albumArtistId else primaryArtistId

artistNames.forEachIndexed { index, artistName ->
val artistId = toUnifiedArtistId(artistName)
artists.putIfAbsent(
Expand Down Expand Up @@ -534,12 +559,13 @@ class JellyfinRepository @Inject constructor(
AlbumEntity(
id = albumId,
title = albumName,
artistName = primaryArtistName,
artistId = primaryArtistId,
artistName = albumDisplayArtistName,
artistId = albumDisplayArtistId,
songCount = 0,
dateAdded = jellyfinSong.dateAdded,
year = jellyfinSong.year,
albumArtUriString = "jellyfin_cover://${jellyfinSong.jellyfinId}"
albumArtUriString = "jellyfin_cover://${jellyfinSong.jellyfinId}",
albumArtist = jellyfinSong.albumArtist?.takeIf { it.isNotBlank() }
)
)

Expand All @@ -549,7 +575,8 @@ class JellyfinRepository @Inject constructor(
title = jellyfinSong.title,
artistName = jellyfinSong.artist.ifBlank { primaryArtistName },
artistId = primaryArtistId,
albumArtist = null,
albumArtist = jellyfinSong.albumArtist?.takeIf { it.isNotBlank() },
albumArtistId = albumArtistId,
albumName = albumName,
albumId = albumId,
contentUriString = "jellyfin://${jellyfinSong.jellyfinId}",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ data class JellyfinSong(
val title: String,
val artist: String,
val artistId: String? = null,
val albumArtist: String? = null,
val album: String,
val albumId: String? = null,
val duration: Long, // milliseconds
Expand All @@ -30,6 +31,7 @@ data class JellyfinSong(
title = "",
artist = "",
artistId = null,
albumArtist = null,
album = "",
albumId = null,
duration = 0L,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import com.lostf1sh.pixelplayeross.data.navidrome.model.NavidromeSong
import com.lostf1sh.pixelplayeross.data.network.navidrome.NavidromeApiService
import com.lostf1sh.pixelplayeross.data.network.navidrome.NavidromeResponseParser
import com.lostf1sh.pixelplayeross.data.preferences.PlaylistPreferencesRepository
import com.lostf1sh.pixelplayeross.data.preferences.UserPreferencesRepository
import com.lostf1sh.pixelplayeross.data.stream.BulkSyncResult
import com.lostf1sh.pixelplayeross.data.stream.CloudMusicUtils
import dagger.hilt.android.qualifiers.ApplicationContext
Expand Down Expand Up @@ -60,6 +61,7 @@ class NavidromeRepository @Inject constructor(
private val dao: NavidromeDao,
private val musicDao: MusicDao,
private val playlistPreferencesRepository: PlaylistPreferencesRepository,
private val userPreferencesRepository: UserPreferencesRepository,
@ApplicationContext private val context: Context
) {
companion object {
Expand Down Expand Up @@ -735,6 +737,10 @@ class NavidromeRepository @Inject constructor(
return
}

// When on, "Group by Album Artist" makes the album's display artist the album artist;
// either way the effective album artist is captured on the song for the Artists tab.
val groupByAlbumArtist = userPreferencesRepository.groupByAlbumArtistFlow.first()

val songs = ArrayList<SongEntity>(navidromeSongs.size)
val artists = LinkedHashMap<Long, ArtistEntity>()
val albums = LinkedHashMap<Long, AlbumEntity>()
Expand All @@ -746,6 +752,25 @@ class NavidromeRepository @Inject constructor(
val primaryArtistName = artistNames.firstOrNull() ?: "Unknown Artist"
val primaryArtistId = toUnifiedArtistId(primaryArtistName)

// Effective album artist (Subsonic albumArtist tag, else primary track artist),
// registered as a real artist row so songs.album_artist_id can join to it.
val effectiveAlbumArtistName = navidromeSong.albumArtist
?.trim()
?.takeIf { it.isNotBlank() }
?: primaryArtistName
val albumArtistId = toUnifiedArtistId(effectiveAlbumArtistName)
artists.putIfAbsent(
albumArtistId,
ArtistEntity(
id = albumArtistId,
name = effectiveAlbumArtistName,
trackCount = 0,
imageUrl = null
)
)
val albumDisplayArtistName = if (groupByAlbumArtist) effectiveAlbumArtistName else primaryArtistName
val albumDisplayArtistId = if (groupByAlbumArtist) albumArtistId else primaryArtistId

artistNames.forEachIndexed { index, artistName ->
val artistId = toUnifiedArtistId(artistName)
artists.putIfAbsent(
Expand Down Expand Up @@ -773,13 +798,14 @@ class NavidromeRepository @Inject constructor(
AlbumEntity(
id = albumId,
title = albumName,
artistName = primaryArtistName,
artistId = primaryArtistId,
artistName = albumDisplayArtistName,
artistId = albumDisplayArtistId,
songCount = 0,
dateAdded = navidromeSong.dateAdded,
year = navidromeSong.year,
albumArtUriString = navidromeSong.coverArtId?.takeIf { it.isNotBlank() }
?.let { "navidrome_cover://$it" }
?.let { "navidrome_cover://$it" },
albumArtist = navidromeSong.albumArtist?.takeIf { it.isNotBlank() }
)
)

Expand All @@ -789,7 +815,8 @@ class NavidromeRepository @Inject constructor(
title = navidromeSong.title,
artistName = navidromeSong.artist.ifBlank { primaryArtistName },
artistId = primaryArtistId,
albumArtist = null,
albumArtist = navidromeSong.albumArtist?.takeIf { it.isNotBlank() },
albumArtistId = albumArtistId,
albumName = albumName,
albumId = albumId,
contentUriString = "navidrome://${navidromeSong.navidromeId}",
Expand Down
Loading