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 @@ -345,14 +345,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 +394,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 @@ -1630,6 +1642,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 @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -89,33 +89,54 @@ class TelegramClientManager @Inject constructor(
is TdApi.AuthorizationStateWaitTdlibParameters -> {
val databaseDirectory = File(context.filesDir, "tdlib").absolutePath
val filesDirectory = File(context.filesDir, "tdlib_files").absolutePath

// Based on error message and typical TDLib params structure for flat constructors:
// useTestDc, databaseDir, filesDir, encryptionKey, useFileDatabase, useChatInfoDatabase, useMessageDatabase, useSecretChats, apiId, apiHash, systemLanguage, deviceModel, systemVersion, applicationVersion, enableStorageOptimizer, ignoreFileNames

// Note: The order varies by version. I will try the most common flat signature.
// If this fails, I might need to revert to using the object but finding why the object constructor failed.
// Actually, often in Java bindings, you have to set fields on the object passed to SetTdlibParameters.
// But if SetTdlibParameters ONLY has a multi-arg constructor, I must use it.

// Let's assume the error message `constructor(p0: Boolean, p1: String!, ...)` matches the fields.

client?.send(TdApi.SetTdlibParameters(
false, // useTestDc
databaseDirectory,
filesDirectory,
null, // databaseEncryptionKey
true, // useFileDatabase
true, // useChatInfoDatabase
true, // useMessageDatabase
false, // useSecretChats
BuildConfig.TELEGRAM_API_ID,
BuildConfig.TELEGRAM_API_HASH,
"en", // systemLanguageCode
"PixelPlayer Instance", // deviceModel
android.os.Build.VERSION.RELEASE, // systemVersion
BuildConfig.VERSION_NAME
), defaultHandler)

// Flat positional constructor, confirmed against this exact tdlibx 1.8.56
// build's actual compiled signature (via a real compile error, not assumed
// from docs — see commit message). This build's TdApi.SetTdlibParameters has
// no constructor that takes a TdlibParameters object, only this flat one and
// a no-arg one, contrary to the official TDLib docs/example for a different
// binding. Confirmed signature:
// (useTestDc: Boolean, databaseDirectory: String, filesDirectory: String,
// databaseEncryptionKey: ByteArray?, useFileDatabase: Boolean,
// useChatInfoDatabase: Boolean, useMessageDatabase: Boolean,
// useSecretChats: Boolean, apiId: Int, apiHash: String,
// systemLanguageCode: String, deviceModel: String, systemVersion: String,
// applicationVersion: String)
// Named locals here instead of bare positional literals so a misordering is
// easier to spot on review, without reintroducing the ambiguity of guessing
// at field names that don't apply to this build's API shape.
val useTestDc = false
val databaseEncryptionKey: ByteArray? = null
val useFileDatabase = true
val useChatInfoDatabase = true
val useMessageDatabase = true
val useSecretChats = false
val apiId = BuildConfig.TELEGRAM_API_ID
val apiHash = BuildConfig.TELEGRAM_API_HASH
val systemLanguageCode = "en"
val deviceModel = "PixelPlayer Instance"
val systemVersion = android.os.Build.VERSION.RELEASE
val applicationVersion = BuildConfig.VERSION_NAME

client?.send(
TdApi.SetTdlibParameters(
useTestDc,
databaseDirectory,
filesDirectory,
databaseEncryptionKey,
useFileDatabase,
useChatInfoDatabase,
useMessageDatabase,
useSecretChats,
apiId,
apiHash,
systemLanguageCode,
deviceModel,
systemVersion,
applicationVersion
),
defaultHandler
)
}
is TdApi.AuthorizationStateWaitPhoneNumber -> {
// UI should prompt for phone number
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -177,10 +177,17 @@ class TelegramRepository @Inject constructor(
// looks like a thread/message identifier. We skip fields named
// exactly "id" because in many TDLib Java builds that field is a
// String composite key, not the numeric thread ID.
//
// "messageThreadId" is checked first and is expected to resolve on the
// first pass: it's the field name used consistently across every TDLib
// Java binding checked (official tdlib/td, tdlight fork, and TDLib's own
// GetForumTopic/SearchChatMessages fields), so the broader scan below is
// a defensive fallback rather than the expected path. Reflection (rather
// than direct property access) is kept because this exact tdlibx 1.8.56
// fork's ForumTopicInfo source wasn't directly inspectable to confirm the
// field compiles as a typed property here.
val threadId: Long = run {
// Log all fields once so we can confirm the correct name in Logcat
val allFields = info.javaClass.declaredFields
Timber.d("ForumTopicInfo fields: ${allFields.map { "${it.name}:${it.type.simpleName}" }}")

var resolved = 0L
// Prefer the most specific name first, skip bare "id" (likely String)
Expand All @@ -201,7 +208,6 @@ class TelegramRepository @Inject constructor(
else -> 0L
}
if (candidate != 0L) {
Timber.d("ForumTopicInfo: resolved threadId via field '$name' = $candidate")
resolved = candidate
break
}
Expand Down
Loading
Loading