Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
76aa05c
feat(cast): add AC4 audio codec detection for ISO BMFF containers
Amonoman Jun 22, 2026
9758cf7
Merge branch 'PixelPlayerHQ:master' into master
Amonoman Jun 22, 2026
b0d12f2
Merge branch 'PixelPlayerHQ:master' into master
Amonoman Jun 25, 2026
d498b37
perf(sync): parallelize Telegram metadata refinement in SyncWorker
Amonoman Jun 26, 2026
738068d
perf(db): skip per-chunk orphan cleanup scans in Telegram sync
Amonoman Jun 26, 2026
ac9eb30
perf(media): hoist replay-gain regex out of the per-song hot path
Amonoman Jun 26, 2026
ab21695
fix(telegram): use field assignment instead of guessed flat construct…
Amonoman Jun 27, 2026
43606bf
chore(telegram): remove per-topic debug logging in forum thread ID re…
Amonoman Jun 27, 2026
aba5ee4
perf(sync): scale Telegram metadata-read concurrency to device cores
Amonoman Jun 27, 2026
7435d62
perf(sync): eliminate redundant per-row cursor reads and hash calls
Amonoman Jun 27, 2026
5b83766
fix(telegram): revert SetTdlibParameters to its actual flat constructor
Amonoman Jun 27, 2026
c7e60d2
perf(sync): stop double-querying the DB for changed MediaStore songs
Amonoman Jun 27, 2026
1b72a5f
perf(library): debounce artist image prefetch instead of cancel-and-r…
Amonoman Jun 27, 2026
ca3a046
perf(sync): replace whole-library artist/album preload with per-chunk…
Amonoman Jun 28, 2026
fab2739
perf(library): debounce getAudioFiles to avoid full-list rebuild on e…
Amonoman Jun 28, 2026
5578001
perf(library): debounce getAlbums, getArtists, and getMusicFolders
Amonoman Jun 28, 2026
f3cb79b
diag(telegram): add per-batch progress logging to getAudioMessages pa…
Amonoman Jun 28, 2026
4e1c90a
fix(telegram): fix GetChatMessageCount call for updated TDLib API
Amonoman Jun 28, 2026
2108d9d
chore(i18n): remove stale locale string keys not present in base values/
Amonoman Jun 28, 2026
4dbec45
chore(i18n): remove stale Arabic plurals.xml with renamed/deleted keys
Amonoman Jun 28, 2026
7834955
fix: resolve easy Kotlin compiler warnings
Amonoman Jun 28, 2026
6cb912a
feat(telegram): add progress UI for large channel and forum syncs
Amonoman Jun 28, 2026
cfb40c8
fix(telegram): fix TelegramSyncProgress placement and GetChatMessageC…
Amonoman Jun 28, 2026
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
Expand Up @@ -54,7 +54,7 @@ class AiPlaylistGenerator @Inject constructor(
val genre = song.genre?.replace("\"", "'")?.take(15) ?: "?"
if (index > 0) append(",\n")
if (useExtendedFields) {
val album = song.album?.replace("\"", "'")?.take(25) ?: "?"
val album = song.album.replace("\"", "'")?.take(25) ?: "?"
val dur = song.duration
val fav = if (song.isFavorite) "1" else "0"
append("""{"id":"${song.id}","t":"$title","a":"$artist","g":"$genre","al":"$album","d":$dur,"f":$fav,"s":$score}""")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,20 @@ data class MimeTypeCountRow(
val count: Int
)

/** Result row for getArtistsByNormalizedNames — used by syncTelegramData's per-chunk dedup. */
data class ArtistLookupRow(
val id: Long,
val name: String,
val imageUrl: String?
)

/** Result row for getAlbumsByNormalizedTitles — used by syncTelegramData's per-chunk dedup. */
data class AlbumLookupRow(
val id: Long,
val title: String,
val artistName: String
)

@Dao
interface MusicDao {

Expand Down Expand Up @@ -345,14 +359,24 @@ interface MusicDao {
/**
* Incrementally sync music data: upsert new/modified songs and remove deleted ones.
* More efficient than clear-and-replace for large libraries with few changes.
*
* @param cleanupOrphans Whether to run the orphaned-album/artist cleanup scans at the end
* of this call. These are full-table NOT EXISTS scans against songs / cross-refs, so they
* get expensive when this function is called many times in a row for chunked inserts (e.g.
* syncing 100k+ songs in batches of a few hundred). Callers that flush several chunks of
* pure inserts in a loop should pass `false` for every chunk and run cleanup once after the
* loop finishes instead, since inserting songs/albums/artists can never create an orphan —
* only the deletedSongIds path below can. Defaults to true to preserve existing behavior
* for callers that sync once per call (e.g. single-pass deletions, single-batch syncs).
*/
@Transaction
suspend fun incrementalSyncMusicData(
songs: List<SongEntity>,
albums: List<AlbumEntity>,
artists: List<ArtistEntity>,
crossRefs: List<SongArtistCrossRef>,
deletedSongIds: List<Long>
deletedSongIds: List<Long>,
cleanupOrphans: Boolean = true
) {
// Protect cloud songs from deletion during generic media scan
// Only allow explicit deletions if the list is non-empty.
Expand Down Expand Up @@ -384,9 +408,11 @@ interface MusicDao {
insertSongArtistCrossRefs(chunk)
}

// Clean up orphaned albums and artists
deleteOrphanedAlbums()
deleteOrphanedArtists()
// Clean up orphaned albums and artists. Skippable via cleanupOrphans — see kdoc above.
if (cleanupOrphans) {
deleteOrphanedAlbums()
deleteOrphanedArtists()
}
}

// --- Directory Helper ---
Expand Down Expand Up @@ -1528,6 +1554,21 @@ interface MusicDao {
@Query("SELECT id FROM artists WHERE LOWER(TRIM(name)) = LOWER(TRIM(:name)) LIMIT 1")
suspend fun getArtistIdByNormalizedName(name: String): Long?

// Used by syncTelegramData's per-chunk dedup: looks up only the artists relevant to the
// current chunk's songs instead of loading the entire artists table. normalizedNames
// must already be trim().lowercase()'d by the caller, matching the LOWER(TRIM(name))
// comparison here.
@Query("SELECT id, name, image_url AS imageUrl FROM artists WHERE LOWER(TRIM(name)) IN (:normalizedNames)")
suspend fun getArtistsByNormalizedNames(normalizedNames: List<String>): List<ArtistLookupRow>

// Used by syncTelegramData's per-chunk dedup: looks up only the albums relevant to the
// current chunk's songs instead of loading the entire albums table. Filters by title only
// (not the full title+artist key) since SQLite IN() doesn't match tuples directly; the
// caller reconstructs the same "title_artistName" key in Kotlin and discards any rows
// whose artist doesn't match, same as the title-only candidate set this produces.
@Query("SELECT id, title, artist_name AS artistName FROM albums WHERE LOWER(TRIM(title)) IN (:normalizedTitles)")
suspend fun getAlbumsByNormalizedTitles(normalizedTitles: List<String>): List<AlbumLookupRow>

@Query("SELECT MAX(id) FROM artists")
suspend fun getMaxArtistId(): Long?

Expand Down Expand Up @@ -1630,6 +1671,17 @@ interface MusicDao {
@Query("DELETE FROM artists WHERE NOT EXISTS (SELECT 1 FROM song_artist_cross_ref WHERE song_artist_cross_ref.artist_id = artists.id)")
suspend fun deleteOrphanedArtists()

/**
* Runs both orphan-cleanup scans once. Call this after a loop of
* incrementalSyncMusicData(..., cleanupOrphans = false) calls, instead of paying for the
* cleanup scan on every chunk.
*/
@Transaction
suspend fun cleanupOrphanedMusicData() {
deleteOrphanedAlbums()
deleteOrphanedArtists()
}

// --- Favorite Operations ---
@Query("UPDATE songs SET is_favorite = :isFavorite WHERE id = :songId")
suspend fun setFavoriteStatus(songId: Long, isFavorite: Boolean)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ object AudioMetadataReader {
*/
private const val VERBOSE = false

// Compiled once instead of on every parseReplayGainDb() call. This function runs up to
// twice per song (track gain + album gain), each checking up to 3 property keys, so a
// per-call Regex(...) construction adds up across a large library sync.
private val DB_SUFFIX_REGEX = Regex("(?i)[dD][bB]")

fun read(context: Context, uri: Uri): AudioMetadata? {
val tempFile = createTempAudioFileFromUri(context, uri) ?: run {
Timber.tag(TAG).w("Unable to create temp file for uri: $uri")
Expand Down Expand Up @@ -257,7 +262,7 @@ object AudioMetadataReader {
val cleanedValue = rawValue
?.trim()
?.replace(',', '.')
?.replace(Regex("(?i)[dD][bB]"), "")
?.replace(DB_SUFFIX_REGEX, "")
?.trim()
?: return null
return cleanedValue.toFloatOrNull()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,7 @@ class MediaStoreSongRepository @Inject constructor(
val minDuration = values[3] as Int
Triple(allowedDirs, blockedDirs, minDuration)
}.flatMapLatest { (allowedDirs, blockedDirs, minDuration) ->
val minDurationMs = minDuration as Int
val minDurationMs = minDuration
val musicIds = getFilteredSongIds(allowedDirs.toList(), blockedDirs.toList(), minDurationMs)
val genreMap = getSongIdToGenreMap(context.contentResolver)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,12 +79,17 @@ import androidx.paging.PagingData
import androidx.paging.map
import androidx.paging.filter
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.CoroutineScope

@OptIn(ExperimentalCoroutinesApi::class)
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@Singleton
class MusicRepositoryImpl @Inject constructor(
@ApplicationContext private val context: Context,
Expand All @@ -107,6 +112,27 @@ class MusicRepositoryImpl @Inject constructor(
private const val SEARCH_RESULTS_LIMIT = 100
private const val UNKNOWN_GENRE_NAME = "Unknown"
private const val UNKNOWN_GENRE_ID = "unknown"
// How long the artist list must stop changing before a prefetch is actually
// triggered. A judgment call, not a profiled-optimal value: long enough to coalesce
// the rapid chunk-commit cadence of an active sync (observed roughly every few
// hundred ms to ~1s during a large Telegram/MediaStore sync), short enough that a
// normal, one-off addition of a few songs still gets artist images fetched promptly.
private const val ARTIST_PREFETCH_DEBOUNCE_MS = 1500L
// Shorter than ARTIST_PREFETCH_DEBOUNCE_MS on purpose: that one delays a background
// side effect the user doesn't directly perceive, this one delays the songs list
// itself, so a normal one-off action (e.g. deleting a song) should still feel
// responsive. Still long enough to coalesce the rapid commit cadence of an active
// sync. A judgment call, not a profiled-optimal value.
// Shared across getAudioFiles/getAlbums/getArtists/getMusicFolders: all four wrap a
// Room Flow query that re-emits on every relevant table commit, with a
// distinctUntilChanged() that doesn't dedupe genuinely-different successive emissions
// during an active sync (counts/fields change with each commit). Debouncing here
// coalesces a burst of rapid re-emissions into one UI update once the list settles,
// instead of paying a full conversion + downstream rebuild cost on every commit.
// Intentionally short (600ms): these delay directly user-visible lists, not a
// background side effect, so a normal one-off action should still feel responsive.
// A judgment call, not a profiled-optimal value.
private const val LIBRARY_LIST_DEBOUNCE_MS = 600L
}

private val directoryScanMutex = Mutex()
Expand All @@ -116,11 +142,18 @@ class MusicRepositoryImpl @Inject constructor(
enablePlaceholders = true,
maxSize = 250
)
// Tracks the active prefetch job so a new flow emission cancels the previous one.
@Volatile private var prefetchJob: Job? = null
@Volatile private var currentSongArtistPrefetchJob: Job? = null
@Volatile private var currentSongArtistPrefetchSongId: Long? = null
@Volatile private var telegramDownloadSyncObserverStarted = false
@Volatile private var artistPrefetchObserverStarted = false
// Buffers the latest artist list for the debounced prefetch trigger below. Capacity 1 +
// DROP_OLDEST means a burst of rapid emissions (e.g. one per sync chunk commit) only ever
// holds the single most recent list, rather than queuing every intermediate one.
private val artistPrefetchTrigger = MutableSharedFlow<List<Artist>>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
private val telegramCacheManager: com.theveloper.pixelplay.data.telegram.TelegramCacheManager
get() = telegramCacheManagerProvider.get()
override val telegramRepository: com.theveloper.pixelplay.data.telegram.TelegramRepository
Expand Down Expand Up @@ -177,6 +210,40 @@ class MusicRepositoryImpl @Inject constructor(
}
}

// getArtists()'s underlying query re-emits on every artist/song insert during a sync —
// its own song-count column changes with every song attributed to an artist, so
// distinctUntilChanged() upstream does not dedupe these away. Previously each emission
// cancelled any in-flight prefetch and started a new one from scratch (see git history),
// which on a 100k-song sync meant the prefetch was restarted, cancelled, and discarded
// dozens of times in rapid succession — confirmed via a field performance report showing
// repeated short-lived artist_image_prefetch_start/end pairs (shrinking artist counts,
// sub-4-second durations) tightly interleaved with 600-1000ms+ main-thread frame stalls.
//
// debounce() here means a burst of emissions during active syncing collapses into a
// single prefetch attempt once the list has stopped changing for ARTIST_PREFETCH_DEBOUNCE_MS,
// instead of restarting on every single one. collectLatest() still cancels a prefetch that's
// already running if a newer list arrives after debounce, same intent as the previous
// manual prefetchJob?.cancel(), just without redoing that bookkeeping by hand.
//
// ARTIST_PREFETCH_DEBOUNCE_MS is a judgment call, not a profiled-optimal value: long enough
// to coalesce the rapid chunk-commit cadence of an active sync, short enough that a normal,
// one-off addition of a few songs still gets its artist images fetched promptly.
private fun ensureArtistPrefetchObserverStarted() {
if (artistPrefetchObserverStarted) return
artistPrefetchObserverStarted = true

repositoryScope.launch {
artistPrefetchTrigger
.debounce(ARTIST_PREFETCH_DEBOUNCE_MS)
.collectLatest { artists ->
val missingImages = artists.missingImageCandidates()
if (missingImages.isNotEmpty()) {
artistImageRepository.prefetchArtistImages(missingImages)
}
}
}
}

private fun List<Artist>.missingImageCandidates(): List<Pair<Long, String>> =
asSequence()
.filter { it.effectiveImageUrl.isNullOrBlank() && it.name.isNotBlank() }
Expand All @@ -202,9 +269,13 @@ class MusicRepositoryImpl @Inject constructor(
)
)
}.flatMapLatest { it }
}.map { entities ->
entities.map { it.toSong() }
}.distinctUntilChanged().conflate().flowOn(Dispatchers.IO)
}
// See LIBRARY_LIST_DEBOUNCE_MS. Placed before map{} so debounced-away emissions
// don't pay the toSong() conversion cost either.
.debounce(LIBRARY_LIST_DEBOUNCE_MS)
.map { entities ->
entities.map { it.toSong() }
}.distinctUntilChanged().conflate().flowOn(Dispatchers.IO)
}

@OptIn(ExperimentalCoroutinesApi::class)
Expand Down Expand Up @@ -447,6 +518,9 @@ class MusicRepositoryImpl @Inject constructor(
}.flatMapLatest { (allowedDirs, blockedDirs) ->
val (allowedParentDirs, applyFilter) = computeAllowedDirs(allowedDirs, blockedDirs)
musicDao.getAlbums(allowedParentDirs, applyFilter, storageFilter.toFilterMode(), minTracks)
// See LIBRARY_LIST_DEBOUNCE_MS. Placed before map{} so debounced-away
// emissions don't pay the toAlbum() conversion cost either.
.debounce(LIBRARY_LIST_DEBOUNCE_MS)
.map { entities -> entities.map { it.toAlbum() } }
.distinctUntilChanged()
}.conflate().flowOn(Dispatchers.IO)
Expand All @@ -470,21 +544,21 @@ class MusicRepositoryImpl @Inject constructor(
applyDirectoryFilter = applyFilter,
filterMode = storageFilter.toFilterMode()
)
// See LIBRARY_LIST_DEBOUNCE_MS. This debounces the core artist list itself;
// the prefetch trigger below has its own, separate, longer debounce
// (ARTIST_PREFETCH_DEBOUNCE_MS) since it's a background side effect rather
// than a directly user-visible list.
.debounce(LIBRARY_LIST_DEBOUNCE_MS)
.distinctUntilChanged()
.map { entities ->
val artists = entities.map { it.toArtist() }
// Trigger prefetch for missing images (non-blocking)
val missingImages = artists.missingImageCandidates()
if (missingImages.isNotEmpty()) {
// Cancel any in-flight prefetch before starting a new one — the flow
// can emit multiple times during sync, and concurrent launches would
// create N × artist-count coroutines simultaneously.
prefetchJob?.cancel()
prefetchJob = repositoryScope.launch {
artistImageRepository.prefetchArtistImages(missingImages)
}
}
artists
.map { entities -> entities.map { it.toArtist() } }
.onEach { artists ->
// Feed the debounced trigger rather than computing missingImages and
// launching a prefetch directly here. See ensureArtistPrefetchObserverStarted()
// for why: this Flow can emit many times in quick succession during a sync,
// and the previous per-emission cancel-and-restart approach wasted real
// work and contended for CPU/IO across the whole app each time it restarted.
ensureArtistPrefetchObserverStarted()
artistPrefetchTrigger.tryEmit(artists)
}
}.conflate().flowOn(Dispatchers.IO)
}
Expand Down Expand Up @@ -991,18 +1065,25 @@ class MusicRepositoryImpl @Inject constructor(
allowedParentDirs = allowedParentDirs,
applyDirectoryFilter = applyDirectoryFilter,
filterMode = storageFilter.toFilterMode()
).map { folderSongs ->
folderTreeBuilder.buildFolderTree(
folderSongs = folderSongs,
allowedDirs = config.allowedDirs,
blockedDirs = config.blockedDirs,
isFolderFilterActive = config.isFolderFilterActive,
folderSource = config.folderSource,
context = context
)
}
)
)
}.flatMapLatest { it }
// See LIBRARY_LIST_DEBOUNCE_MS. Placed before buildFolderTree below — an
// even more expensive step than the conversions in the other library list
// flows below, previously fused directly onto the raw emission with no
// distinctUntilChanged() at all, so debounced-away emissions now skip the
// rebuild and its allocation cost entirely instead of paying it on every commit.
.debounce(LIBRARY_LIST_DEBOUNCE_MS)
.map { folderSongs ->
folderTreeBuilder.buildFolderTree(
folderSongs = folderSongs,
allowedDirs = config.allowedDirs,
blockedDirs = config.blockedDirs,
isFolderFilterActive = config.isFolderFilterActive,
folderSource = config.folderSource,
context = context
)
}
}.conflate().flowOn(Dispatchers.IO)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ internal object IsoBmffAudioCodecDetector {
"mp4a" -> "audio/mp4a-latm"
"ac-3" -> "audio/ac3"
"ec-3" -> "audio/eac3"
"ac-4" -> "audio/ac4"
"flac" -> "audio/flac"
"opus" -> "audio/opus"
else -> null
Expand Down
Loading
Loading