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
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 @@ -1522,12 +1548,34 @@ interface MusicDao {
@Query("UPDATE artists SET image_url = :imageUrl WHERE id = :artistId")
suspend fun updateArtistImageUrl(artistId: Long, imageUrl: String)

// Clears the persistent "no image found" sentinel from all artists so the next
// prefetch attempt will retry Deezer for them. Called by ArtistImageRepository.clearCache()
// when the user forces a refresh. The sentinel value must match NO_IMAGE_SENTINEL in
// ArtistImageRepository.
@Query("UPDATE artists SET image_url = NULL WHERE image_url = 'no_image'")
suspend fun clearArtistNoImageSentinels()

@Query("SELECT id FROM artists WHERE name = :name LIMIT 1")
suspend fun getArtistIdByName(name: String): Long?

@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 +1678,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 @@ -88,9 +88,13 @@ class TelegramCoilFetcher(
val quality = uri.getQueryParameter("quality")
val isThumbnailRequest = quality == "thumb"

// Priority 1: Try embedded art from FULLY DOWNLOADED audio files only
// This avoids IO contention during streaming while still getting high-quality art for buffered files
val embeddedArtPath = tryExtractEmbeddedArtIfSafe(chatId, messageId)
// Priority 1: Try embedded art from FULLY DOWNLOADED audio files only.
// Fetch the message here once and pass it in so tryExtractEmbeddedArtIfSafe doesn't
// need to call getMessage a second time — previously this caused two TDLib GetMessage
// JNI round-trips per fetch call (one here, one inside tryExtractEmbeddedArtIfSafe).
val message = telegramRepository.getMessage(chatId, messageId) ?: return null

val embeddedArtPath = tryExtractEmbeddedArtIfSafe(chatId, messageId, message)
if (embeddedArtPath != null) {
Timber.v("TelegramCoilFetcher: Using embedded art for $uri")
return SourceResult(
Expand All @@ -102,9 +106,6 @@ class TelegramCoilFetcher(
dataSource = DataSource.DISK
)
}

// Fetch message to get thumbnail info and minithumbnail
val message = telegramRepository.getMessage(chatId, messageId) ?: return null

// Priority 2: Minithumbnail Fallback (Low Res - embedded in message)
// For thumbnail requests, we check this early to avoid network calls if possible
Expand Down Expand Up @@ -205,7 +206,11 @@ class TelegramCoilFetcher(
* SAFETY: Only extracts if file is fully downloaded to avoid IO contention during streaming.
* Returns the path to the cached art file if successful, null otherwise.
*/
private suspend fun tryExtractEmbeddedArtIfSafe(chatId: Long, messageId: Long): String? {
private suspend fun tryExtractEmbeddedArtIfSafe(
chatId: Long,
messageId: Long,
message: TdApi.Message
): String? {
val key = "${chatId}_${messageId}"
val cachedArtFile = File(cacheDir, "telegram_embedded_art_${key}.jpg")
val noArtMarker = File(cacheDir, "telegram_embedded_art_${key}_none")
Expand All @@ -232,8 +237,7 @@ class TelegramCoilFetcher(
}

// 4. Proceed with Extraction
// Get the message to find the audio file ID
val message = telegramRepository.getMessage(chatId, messageId) ?: return null
// audioFileId resolved from the already-fetched message passed in by fetch()
val audioFileId = extractAudioFileId(message.content) ?: return null

// Check if the audio file is already downloaded
Expand Down Expand Up @@ -320,15 +324,6 @@ class TelegramCoilFetcher(
}
}

/**
* Extracts the thumbnail file ID from a message.
* Supports MessageAudio and MessageDocument content types.
*/
private suspend fun extractThumbnailFileId(chatId: Long, messageId: Long): Int? {
val message = telegramRepository.getMessage(chatId, messageId) ?: return null
return extractFileIdFromContent(message.content)
}

/**
* Extracts the file ID from message content (audio thumbnail or document thumbnail).
* Priority: albumCoverThumbnail > externalAlbumCovers > document thumbnail
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 @@ -15,6 +15,8 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
Expand Down Expand Up @@ -51,6 +53,12 @@ class ArtistImageRepository @Inject constructor(
private const val TARGET_CUSTOM_IMAGE_MAX_DIMENSION_PX = 2_048
private const val TARGET_CUSTOM_IMAGE_MAX_PIXELS = 4_194_304L // 2048x2048

// Sentinel written to artists.image_url when Deezer has no image for an artist.
// Persists the "not found" result across cold starts, so the same 503-artist sweep
// doesn't repeat on every app launch. Cleared by clearArtistNoImageSentinels()
// when the user explicitly refreshes artist images (clearCache()).
internal const val NO_IMAGE_SENTINEL = "no_image"

internal fun calculateCustomImageSampleSize(width: Int, height: Int): Int {
var sampleSize = 1
while (
Expand Down Expand Up @@ -106,6 +114,13 @@ class ArtistImageRepository @Inject constructor(
canonicalArtistId to cachedUrl
}
if (!dbCachedUrl.isNullOrEmpty()) {
// Treat the sentinel as a persisted "not found" — same as failedFetches but
// survives cold starts. Don't put it in memoryCache (it's not a URL), and
// don't try to upgrade it or write it back.
if (dbCachedUrl == NO_IMAGE_SENTINEL) {
failedFetches.add(normalizedName) // also populate in-memory set for this session
return null
}
val upgradedDbUrl = upgradeToHighResDeezerUrl(dbCachedUrl)
memoryCache.put(normalizedName, upgradedDbUrl)
if (upgradedDbUrl != dbCachedUrl) {
Expand All @@ -132,21 +147,27 @@ class ArtistImageRepository @Inject constructor(
) {
mapOf("artistCount" to artists.size.toString())
}
// Pre-filter before chunking: exclude artists whose normalized name is already in
// failedFetches or memoryCache so we never create coroutine objects for them at all.
// Previously this check happened *inside* each async lambda — meaning 503 failed
// artists each got their own coroutine created, launched, and immediately discarded
// on every trigger (every 7-19 seconds per the performance report), generating
// ~400-600ms frame stalls continuously. Filtering first means the chunked/async
// machinery only ever touches artists that actually need a network call.
val actionable = artists.filter { (_, artistName) ->
val normalized = artistName.trim().lowercase()
memoryCache.get(normalized) == null && !failedFetches.contains(normalized)
}
// Process in small chunks to avoid creating hundreds of coroutines simultaneously.
// Without this, a library with 500 artists creates 500 coroutine objects at once, all
// suspended at the semaphore, exhausting the heap and triggering OOM in coroutine machinery.
try {
artists.chunked(PREFETCH_CONCURRENCY * 4).forEach { chunk ->
actionable.chunked(PREFETCH_CONCURRENCY * 4).forEach { chunk ->
chunk.map { (artistId, artistName) ->
async {
try {
val normalizedName = artistName.trim().lowercase()
if (memoryCache.get(normalizedName) == null && !failedFetches.contains(normalizedName)) {
prefetchSemaphore.withPermit {
getArtistImageUrl(artistName, artistId)
}
} else {
Timber.tag(TAG).d("Skipping prefetch for $artistName") //check
prefetchSemaphore.withPermit {
getArtistImageUrl(artistName, artistId)
}
} catch (e: CancellationException) {
throw e
Expand Down Expand Up @@ -213,17 +234,23 @@ class ArtistImageRepository @Inject constructor(
}
} else {
Timber.tag(TAG).d("No Deezer artist found for: $artistName")
failedFetches.add(normalizedName) // Mark as failed
failedFetches.add(normalizedName)
// Persist so the next cold start doesn't retry this artist unnecessarily.
// Cleared by clearArtistNoImageSentinels() when the user forces a refresh.
musicDao.updateArtistImageUrl(artistId, NO_IMAGE_SENTINEL)
null
}
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Timber.tag(TAG).e("Error fetching artist image for $artistName: ${e.message}")
// Consider transient errors? For now treating as failed to avoid spam.
if(e !is java.net.SocketTimeoutException) {
// Transient errors (timeout) might succeed on retry; don't sentinel those.
// Persistent errors (not-found, API error) get the sentinel so the next cold
// start doesn't retry them.
if (e !is java.net.SocketTimeoutException) {
failedFetches.add(normalizedName)
musicDao.updateArtistImageUrl(artistId, NO_IMAGE_SENTINEL)
}
null
} finally {
Expand Down Expand Up @@ -258,6 +285,11 @@ class ArtistImageRepository @Inject constructor(
fun clearCache() {
memoryCache.evictAll()
failedFetches.clear()
// Also clear persisted no-image sentinels so the next prefetch retries all artists.
@OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.IO) {
try { musicDao.clearArtistNoImageSentinels() } catch (e: Exception) { /* best-effort */ }
}
}

/**
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
Loading
Loading