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
Expand Up @@ -23,7 +23,9 @@ data class AudioMetadata(
val year: Int?,
val bitrate: Int?,
val sampleRate: Int?,
val artwork: AudioMetadataArtwork?
val artwork: AudioMetadataArtwork?,
val replayGainTrackGainDb: Float? = null,
val replayGainAlbumGainDb: Float? = null
)

data class AudioMetadataArtwork(
Expand Down Expand Up @@ -85,6 +87,14 @@ object AudioMetadataReader {
val discNumber = discString?.substringBefore('/')?.toIntOrNull()
val year = propertyMap["DATE"]?.firstOrNull()?.takeIf { it.isNotBlank() }?.take(4)?.toIntOrNull()
?: propertyMap["YEAR"]?.firstOrNull()?.takeIf { it.isNotBlank() }?.toIntOrNull()
val replayGainTrackGainDb = extractReplayGainDb(
propertyMap = propertyMap,
keys = listOf("REPLAYGAIN_TRACK_GAIN", "REPLAYGAIN_TRACK_GAIN_DB", "R128_TRACK_GAIN")
)
val replayGainAlbumGainDb = extractReplayGainDb(
propertyMap = propertyMap,
keys = listOf("REPLAYGAIN_ALBUM_GAIN", "REPLAYGAIN_ALBUM_GAIN_DB", "R128_ALBUM_GAIN")
)

Log.w(TAG, "TagLib result for ${file.name}: title=$title, artist=$artist, album=$album, genre=$genre")

Expand Down Expand Up @@ -124,7 +134,9 @@ object AudioMetadataReader {
year = year ?: fallback?.year,
bitrate = bitrate ?: fallback?.bitrate,
sampleRate = sampleRate ?: fallback?.sampleRate,
artwork = artwork ?: fallback?.artwork
artwork = artwork ?: fallback?.artwork,
replayGainTrackGainDb = replayGainTrackGainDb ?: fallback?.replayGainTrackGainDb,
replayGainAlbumGainDb = replayGainAlbumGainDb ?: fallback?.replayGainAlbumGainDb
)
}
} catch (error: Exception) {
Expand Down Expand Up @@ -199,4 +211,28 @@ object AudioMetadataReader {
null
}
}

private fun extractReplayGainDb(
propertyMap: Map<String, Array<String>>,
keys: List<String>
): Float? {
for (key in keys) {
val rawValue = propertyMap[key]?.firstOrNull() ?: continue
val parsedValue = parseReplayGainDb(rawValue)
if (parsedValue != null) {
return parsedValue
}
}
return null
}

private fun parseReplayGainDb(rawValue: String?): Float? {
val cleanedValue = rawValue
?.trim()
?.replace(',', '.')
?.replace(Regex("(?i)[dD][bB]"), "")
?.trim()
?: return null
return cleanedValue.toFloatOrNull()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,22 @@ import org.gagravarr.opus.OpusFile
import org.gagravarr.opus.OpusTags
import org.jaudiotagger.audio.AudioFileIO
import org.jaudiotagger.tag.FieldKey
import org.jaudiotagger.tag.Tag
import org.jaudiotagger.tag.flac.FlacTag
import org.jaudiotagger.tag.id3.AbstractID3v2Frame
import org.jaudiotagger.tag.id3.AbstractID3v2Tag
import org.jaudiotagger.tag.id3.ID3v23Frame
import org.jaudiotagger.tag.id3.ID3v23Frames
import org.jaudiotagger.tag.id3.ID3v23Tag
import org.jaudiotagger.tag.id3.ID3v24Frame
import org.jaudiotagger.tag.id3.ID3v24Frames
import org.jaudiotagger.tag.id3.ID3v24Tag
import org.jaudiotagger.tag.id3.framebody.FrameBodyTXXX
import org.jaudiotagger.tag.images.AndroidArtwork
import org.jaudiotagger.tag.mp4.Mp4Tag
import org.jaudiotagger.tag.mp4.field.Mp4TagReverseDnsField
import org.jaudiotagger.tag.vorbiscomment.VorbisCommentTag
import org.jaudiotagger.tag.wav.WavTag
import timber.log.Timber
import java.io.File
import java.io.FileOutputStream
Expand All @@ -55,6 +70,16 @@ enum class MetadataEditError {
UNKNOWN
}

private const val REPLAYGAIN_TRACK_GAIN_KEY = "REPLAYGAIN_TRACK_GAIN"
private const val REPLAYGAIN_ALBUM_GAIN_KEY = "REPLAYGAIN_ALBUM_GAIN"
private const val MP4_REVERSE_DNS_ISSUER = "com.apple.iTunes"

private sealed interface ReplayGainUpdate {
data object Keep : ReplayGainUpdate
data object Clear : ReplayGainUpdate
data class Set(val formattedValue: String) : ReplayGainUpdate
}


class SongMetadataEditor(
private val context: Context,
Expand Down Expand Up @@ -96,6 +121,27 @@ class SongMetadataEditor(
return null
}

private fun parseReplayGainUpdate(rawValue: String?, fieldName: String): Result<ReplayGainUpdate> {
if (rawValue == null) return Result.success(ReplayGainUpdate.Keep)

val trimmedValue = rawValue.trim()
if (trimmedValue.isEmpty()) return Result.success(ReplayGainUpdate.Clear)

val normalizedValue = trimmedValue
.replace(',', '.')
.replace(Regex("(?i)\\s*d\\s*b\\s*$"), "")
.trim()

val gainDb = normalizedValue.toFloatOrNull()
?: return Result.failure(IllegalArgumentException("$fieldName must be a valid dB value"))

return Result.success(
ReplayGainUpdate.Set(
formattedValue = String.format(Locale.US, "%.2f dB", gainDb)
)
)
}

/**
* Checks if the file can be written to
*/
Expand Down Expand Up @@ -201,6 +247,8 @@ class SongMetadataEditor(
newLyrics: String,
newTrackNumber: Int,
newDiscNumber: Int?,
newReplayGainTrackGainDb: String? = null,
newReplayGainAlbumGainDb: String? = null,
coverArtUpdate: CoverArtUpdate? = null,
): SongMetadataEditResult = withContext(Dispatchers.IO) {
val validationError = validateMetadataInput(newTitle, newArtist, newAlbum, newGenre, newLyrics)
Expand All @@ -218,6 +266,28 @@ class SongMetadataEditor(
val trimmedLyrics = newLyrics.trim()
val trimmedGenre = newGenre.trim()
val normalizedGenre = trimmedGenre.takeIf { it.isNotBlank() }
val replayGainTrackUpdate = parseReplayGainUpdate(
rawValue = newReplayGainTrackGainDb,
fieldName = "Track ReplayGain"
).getOrElse { error ->
return@withContext SongMetadataEditResult(
success = false,
updatedAlbumArtUri = null,
error = MetadataEditError.INVALID_INPUT,
errorMessage = error.message ?: "Invalid Track ReplayGain value"
)
}
val replayGainAlbumUpdate = parseReplayGainUpdate(
rawValue = newReplayGainAlbumGainDb,
fieldName = "Album ReplayGain"
).getOrElse { error ->
return@withContext SongMetadataEditResult(
success = false,
updatedAlbumArtUri = null,
error = MetadataEditError.INVALID_INPUT,
errorMessage = error.message ?: "Invalid Album ReplayGain value"
)
}

val isTelegramSong = songId < 0
val filePath = if (isTelegramSong) {
Expand Down Expand Up @@ -274,6 +344,8 @@ class SongMetadataEditor(
newLyrics = trimmedLyrics,
newTrackNumber = newTrackNumber,
newDiscNumber = newDiscNumber,
replayGainTrackUpdate = replayGainTrackUpdate,
replayGainAlbumUpdate = replayGainAlbumUpdate,
coverArtUpdate = coverArtUpdate
)
} else {
Expand All @@ -287,6 +359,8 @@ class SongMetadataEditor(
newLyrics = trimmedLyrics,
newTrackNumber = newTrackNumber,
newDiscNumber = newDiscNumber,
replayGainTrackUpdate = replayGainTrackUpdate,
replayGainAlbumUpdate = replayGainAlbumUpdate,
coverArtUpdate = coverArtUpdate
)

Expand All @@ -302,6 +376,8 @@ class SongMetadataEditor(
newLyrics = trimmedLyrics,
newTrackNumber = newTrackNumber,
newDiscNumber = newDiscNumber,
replayGainTrackUpdate = replayGainTrackUpdate,
replayGainAlbumUpdate = replayGainAlbumUpdate,
coverArtUpdate = coverArtUpdate
)
} else {
Expand Down Expand Up @@ -488,6 +564,8 @@ class SongMetadataEditor(
newLyrics: String,
newTrackNumber: Int,
newDiscNumber: Int?,
replayGainTrackUpdate: ReplayGainUpdate = ReplayGainUpdate.Keep,
replayGainAlbumUpdate: ReplayGainUpdate = ReplayGainUpdate.Keep,
coverArtUpdate: CoverArtUpdate? = null
): Boolean {
// Check for problematic FLAC files first
Expand Down Expand Up @@ -535,6 +613,8 @@ class SongMetadataEditor(
propertyMap.remove("DISCNUMBER")
}
propertyMap["ALBUMARTIST"] = arrayOf(newArtist)
propertyMap.applyReplayGainUpdate(REPLAYGAIN_TRACK_GAIN_KEY, replayGainTrackUpdate)
propertyMap.applyReplayGainUpdate(REPLAYGAIN_ALBUM_GAIN_KEY, replayGainAlbumUpdate)
Timber.tag(TAG).e("TAGLIB: Updated property map, saving...")

// Save metadata
Expand Down Expand Up @@ -605,6 +685,8 @@ class SongMetadataEditor(
newLyrics: String,
newTrackNumber: Int,
newDiscNumber: Int?,
replayGainTrackUpdate: ReplayGainUpdate = ReplayGainUpdate.Keep,
replayGainAlbumUpdate: ReplayGainUpdate = ReplayGainUpdate.Keep,
coverArtUpdate: CoverArtUpdate? = null
): Boolean {
val targetFile = File(filePath)
Expand Down Expand Up @@ -640,6 +722,8 @@ class SongMetadataEditor(
} else {
tag.deleteField(FieldKey.DISC_NO)
}
tag.applyReplayGainUpdate(REPLAYGAIN_TRACK_GAIN_KEY, replayGainTrackUpdate)
tag.applyReplayGainUpdate(REPLAYGAIN_ALBUM_GAIN_KEY, replayGainAlbumUpdate)

// Update cover art if provided
coverArtUpdate?.let { update ->
Expand Down Expand Up @@ -922,6 +1006,90 @@ private fun MutableMap<String, Array<String>>.upsertOrRemove(key: String, value:
}
}

private fun MutableMap<String, Array<String>>.applyReplayGainUpdate(
key: String,
update: ReplayGainUpdate
) {
when (update) {
ReplayGainUpdate.Keep -> Unit
ReplayGainUpdate.Clear -> remove(key)
is ReplayGainUpdate.Set -> this[key] = arrayOf(update.formattedValue)
}
}

private fun Tag.applyReplayGainUpdate(key: String, update: ReplayGainUpdate) {
when (update) {
ReplayGainUpdate.Keep -> Unit
ReplayGainUpdate.Clear -> removeReplayGainField(key)
is ReplayGainUpdate.Set -> upsertReplayGainField(key, update.formattedValue)
}
}

private fun Tag.upsertReplayGainField(key: String, value: String) {
when (this) {
is AbstractID3v2Tag -> upsertReplayGainId3Field(key, value)
is WavTag -> {
val id3Tag = getID3Tag() ?: ID3v24Tag().also(::setID3Tag)
id3Tag.upsertReplayGainId3Field(key, value)
}
is FlacTag -> setField(key, value)
is VorbisCommentTag -> setField(key, value)
is Mp4Tag -> {
val fieldId = replayGainMp4FieldId(key)
deleteField(fieldId)
setField(Mp4TagReverseDnsField(fieldId, MP4_REVERSE_DNS_ISSUER, key, value))
}
else -> Timber.tag(TAG).w("ReplayGain update is not supported for tag type: ${this::class.java.simpleName}")
}
}

private fun Tag.removeReplayGainField(key: String) {
when (this) {
is AbstractID3v2Tag -> removeReplayGainId3Field(key)
is WavTag -> getID3Tag()?.removeReplayGainId3Field(key)
is FlacTag -> deleteField(key)
is VorbisCommentTag -> deleteField(key)
is Mp4Tag -> deleteField(replayGainMp4FieldId(key))
else -> Timber.tag(TAG).w("ReplayGain removal is not supported for tag type: ${this::class.java.simpleName}")
}
}

private fun AbstractID3v2Tag.upsertReplayGainId3Field(key: String, value: String) {
val frame = if (this is ID3v23Tag) {
ID3v23Frame(ID3v23Frames.FRAME_ID_V3_USER_DEFINED_INFO)
} else {
ID3v24Frame(ID3v24Frames.FRAME_ID_USER_DEFINED_INFO)
}
frame.body = FrameBodyTXXX().apply {
setDescription(key)
setText(value)
}
setField(frame)
}

private fun AbstractID3v2Tag.removeReplayGainId3Field(key: String) {
val frameId = if (this is ID3v23Tag) {
ID3v23Frames.FRAME_ID_V3_USER_DEFINED_INFO
} else {
ID3v24Frames.FRAME_ID_USER_DEFINED_INFO
}
val frames = getFields(frameId)
val iterator = frames.listIterator()
while (iterator.hasNext()) {
val frame = iterator.next() as? AbstractID3v2Frame ?: continue
val body = frame.body as? FrameBodyTXXX ?: continue
if (body.description == key) {
if (frames.size == 1) {
removeFrame(frameId)
} else {
iterator.remove()
}
}
}
}

private fun replayGainMp4FieldId(key: String): String = "----:$MP4_REVERSE_DNS_ISSUER:$key"

// Data classes
data class SongMetadataEditResult(
val success: Boolean,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ fun DailyMixSection(
onNavigateToGenre(song)
showSongInfoSheet = false
},
onEditSong = { newTitle, newArtist, newAlbum, newGenre, newLyrics, newTrackNumber, newDiscNumber, coverArtUpdate ->
onEditSong = { newTitle, newArtist, newAlbum, newGenre, newLyrics, newTrackNumber, newDiscNumber, replayGainTrackGainDb, replayGainAlbumGainDb, coverArtUpdate ->
playerViewModel.editSongMetadata(
song,
newTitle,
Expand All @@ -151,6 +151,8 @@ fun DailyMixSection(
newLyrics,
newTrackNumber,
newDiscNumber,
replayGainTrackGainDb,
replayGainAlbumGainDb,
coverArtUpdate
)
},
Expand Down
Loading
Loading