feat: persist codec format and bitrate in download history

Bump the history schema on both the Kotlin finalizer and the Dart database to v9, adding bitrate (kbps) and format (codec label) columns, and let the download flow fill them from backend/probe metadata so lossy downloads keep a 'AAC 256kbps' label instead of falling back to the stored placeholder. Library filtering and the track metadata screen now read format/bitrate directly from those columns, which also fixes mis-tagged quality badges after re-downloading a track at a different format.

Additional fixes bundled in: EditFileMetadata now routes ReplayGain writes through the M4A path whenever the file starts with ftyp (fixing .flac files that actually hold MP4 containers); GetM4AQuality falls back to the first trak/mdia/mdhd duration when mvhd is zero so EAC3 streams no longer report 0s; and both Kotlin and Dart reject bitrate values below 16 kbps to prevent probe noise from surfacing as '0 kbps' labels. New unit tests cover the EAC3 mdhd fallback and the mis-named M4A replaygain path.
This commit is contained in:
zarzet 2026-05-10 23:18:32 +07:00
parent d664d46ca4
commit 8e605cbd0f
11 changed files with 451 additions and 57 deletions

View file

@ -30,7 +30,7 @@ object NativeDownloadFinalizer {
const val NATIVE_WORKER_CONTRACT_VERSION = 1 const val NATIVE_WORKER_CONTRACT_VERSION = 1
// Native finalizer owns background-safe history writes while Flutter may be suspended. // Native finalizer owns background-safe history writes while Flutter may be suspended.
// Keep this schema contract in sync with Dart HistoryDatabase before bumping either side. // Keep this schema contract in sync with Dart HistoryDatabase before bumping either side.
private const val HISTORY_SCHEMA_VERSION = 8 private const val HISTORY_SCHEMA_VERSION = 9
private val activeFFmpegSessionIds = mutableSetOf<Long>() private val activeFFmpegSessionIds = mutableSetOf<Long>()
private val nativeFFmpegSessionIds = mutableSetOf<Long>() private val nativeFFmpegSessionIds = mutableSetOf<Long>()
private val activeFFmpegSessionLock = Any() private val activeFFmpegSessionLock = Any()
@ -73,6 +73,8 @@ object NativeDownloadFinalizer {
"quality", "quality",
"bit_depth", "bit_depth",
"sample_rate", "sample_rate",
"bitrate",
"format",
"genre", "genre",
"composer", "composer",
"label", "label",
@ -177,6 +179,9 @@ object NativeDownloadFinalizer {
sampleRate = optPositiveInt(result, "actual_sample_rate"), sampleRate = optPositiveInt(result, "actual_sample_rate"),
bitrateKbps = optPositiveBitrateKbps(result, "bitrate") bitrateKbps = optPositiveBitrateKbps(result, "bitrate")
?: optPositiveBitrateKbps(result, "actual_bitrate"), ?: optPositiveBitrateKbps(result, "actual_bitrate"),
audioCodec = normalizeAudioCodec(
result.optString("audio_codec", "").ifBlank { result.optString("format", "") },
),
) )
try { try {
@ -676,7 +681,7 @@ object NativeDownloadFinalizer {
} }
val bitrateKbps = optPositiveBitrateKbps(metadata, "bitrate") val bitrateKbps = optPositiveBitrateKbps(metadata, "bitrate")
?: optPositiveBitrateKbps(metadata, "bit_rate") ?: optPositiveBitrateKbps(metadata, "bit_rate")
if (bitrateKbps != null) { if (bitrateKbps != null && isLossyAudioCodec(state.audioCodec)) {
state.bitrateKbps = bitrateKbps state.bitrateKbps = bitrateKbps
result.put("bitrate", bitrateKbps) result.put("bitrate", bitrateKbps)
} }
@ -737,7 +742,7 @@ object NativeDownloadFinalizer {
format == "AC4" || format == "AC4" ||
(format == "M4A" && (bitDepth == null || bitDepth <= 0)) (format == "M4A" && (bitDepth == null || bitDepth <= 0))
) { ) {
return if (bitrateKbps != null && bitrateKbps > 0) { return if (bitrateKbps != null && bitrateKbps >= 16) {
"$format ${bitrateKbps}kbps" "$format ${bitrateKbps}kbps"
} else { } else {
nonPlaceholderQuality(storedQuality) ?: format nonPlaceholderQuality(storedQuality) ?: format
@ -767,6 +772,13 @@ object NativeDownloadFinalizer {
} }
} }
private fun isLossyAudioCodec(codec: String?): Boolean {
return when (normalizeAudioCodec(codec)) {
"aac", "eac3", "ac3", "ac4", "mp3", "opus", "m4a" -> true
else -> false
}
}
private fun normalizeAudioCodec(codec: String?): String? { private fun normalizeAudioCodec(codec: String?): String? {
val normalized = normalizeOptional(codec) val normalized = normalizeOptional(codec)
?.lowercase(Locale.ROOT) ?.lowercase(Locale.ROOT)
@ -777,6 +789,8 @@ object NativeDownloadFinalizer {
"ec_3" -> "eac3" "ec_3" -> "eac3"
"ac_3" -> "ac3" "ac_3" -> "ac3"
"ac_4" -> "ac4" "ac_4" -> "ac4"
"mp4" -> "m4a"
"ogg" -> "opus"
else -> normalized else -> normalized
} }
} }
@ -796,6 +810,11 @@ object NativeDownloadFinalizer {
private fun nonPlaceholderQuality(quality: String?): String? { private fun nonPlaceholderQuality(quality: String?): String? {
val normalized = normalizeOptional(quality) ?: return null val normalized = normalizeOptional(quality) ?: return null
val bitrateMatch = Regex("\\b(\\d+)\\s*kbps\\b", RegexOption.IGNORE_CASE).find(normalized)
if (bitrateMatch != null) {
val bitrate = bitrateMatch.groupValues.getOrNull(1)?.toIntOrNull()
if (bitrate != null && bitrate < 16) return null
}
val key = normalized.lowercase(Locale.ROOT).replace(Regex("[^a-z0-9]+"), "_").trim('_') val key = normalized.lowercase(Locale.ROOT).replace(Regex("[^a-z0-9]+"), "_").trim('_')
val placeholders = setOf( val placeholders = setOf(
"best", "best",
@ -1654,6 +1673,10 @@ object NativeDownloadFinalizer {
values.put("quality", state.quality) values.put("quality", state.quality)
state.bitDepth?.let { values.put("bit_depth", it) } state.bitDepth?.let { values.put("bit_depth", it) }
state.sampleRate?.let { values.put("sample_rate", it) } state.sampleRate?.let { values.put("sample_rate", it) }
state.bitrateKbps?.takeIf { it >= 16 && isLossyAudioCodec(state.audioCodec) }?.let {
values.put("bitrate", it)
}
normalizeAudioCodec(state.audioCodec)?.let { values.put("format", it) }
values.put("genre", normalizeOptional(result.optString("genre", "").ifBlank { input.request.optString("genre", "") })) values.put("genre", normalizeOptional(result.optString("genre", "").ifBlank { input.request.optString("genre", "") }))
values.put("composer", normalizeOptional(resultString(input, "composer").ifBlank { trackString(input, "composer", requestString(input, "composer")) })) values.put("composer", normalizeOptional(resultString(input, "composer").ifBlank { trackString(input, "composer", requestString(input, "composer")) }))
values.put("label", normalizeOptional(result.optString("label", "").ifBlank { input.request.optString("label", "") })) values.put("label", normalizeOptional(result.optString("label", "").ifBlank { input.request.optString("label", "") }))
@ -1710,6 +1733,8 @@ object NativeDownloadFinalizer {
quality TEXT, quality TEXT,
bit_depth INTEGER, bit_depth INTEGER,
sample_rate INTEGER, sample_rate INTEGER,
bitrate INTEGER,
format TEXT,
genre TEXT, genre TEXT,
composer TEXT, composer TEXT,
label TEXT, label TEXT,
@ -1725,6 +1750,8 @@ object NativeDownloadFinalizer {
ensureHistoryColumn(db, "composer", "ALTER TABLE history ADD COLUMN composer TEXT") ensureHistoryColumn(db, "composer", "ALTER TABLE history ADD COLUMN composer TEXT")
ensureHistoryColumn(db, "total_tracks", "ALTER TABLE history ADD COLUMN total_tracks INTEGER") ensureHistoryColumn(db, "total_tracks", "ALTER TABLE history ADD COLUMN total_tracks INTEGER")
ensureHistoryColumn(db, "total_discs", "ALTER TABLE history ADD COLUMN total_discs INTEGER") ensureHistoryColumn(db, "total_discs", "ALTER TABLE history ADD COLUMN total_discs INTEGER")
ensureHistoryColumn(db, "bitrate", "ALTER TABLE history ADD COLUMN bitrate INTEGER")
ensureHistoryColumn(db, "format", "ALTER TABLE history ADD COLUMN format TEXT")
ensureHistoryColumn(db, "spotify_id_norm", "ALTER TABLE history ADD COLUMN spotify_id_norm TEXT") ensureHistoryColumn(db, "spotify_id_norm", "ALTER TABLE history ADD COLUMN spotify_id_norm TEXT")
ensureHistoryColumn(db, "isrc_norm", "ALTER TABLE history ADD COLUMN isrc_norm TEXT") ensureHistoryColumn(db, "isrc_norm", "ALTER TABLE history ADD COLUMN isrc_norm TEXT")
ensureHistoryColumn(db, "match_key", "ALTER TABLE history ADD COLUMN match_key TEXT") ensureHistoryColumn(db, "match_key", "ALTER TABLE history ADD COLUMN match_key TEXT")
@ -2096,6 +2123,8 @@ object NativeDownloadFinalizer {
putCamel("quality", "quality") putCamel("quality", "quality")
putCamel("bit_depth", "bitDepth") putCamel("bit_depth", "bitDepth")
putCamel("sample_rate", "sampleRate") putCamel("sample_rate", "sampleRate")
putCamel("bitrate", "bitrate")
putCamel("format", "format")
putCamel("genre", "genre") putCamel("genre", "genre")
putCamel("composer", "composer") putCamel("composer", "composer")
putCamel("label", "label") putCamel("label", "label")
@ -2127,11 +2156,12 @@ object NativeDownloadFinalizer {
private fun optPositiveBitrateKbps(obj: JSONObject, key: String): Int? { private fun optPositiveBitrateKbps(obj: JSONObject, key: String): Int? {
val value = optPositiveInt(obj, key) ?: return null val value = optPositiveInt(obj, key) ?: return null
return if (value >= 10000) { val kbps = if (value >= 10000) {
Math.round(value / 1000.0).toInt() Math.round(value / 1000.0).toInt()
} else { } else {
value value
} }
return if (kbps >= 16) kbps else null
} }
private fun positiveOrNull(primary: Int, fallback: Int): Int? { private fun positiveOrNull(primary: Int, fallback: Int): Int? {

View file

@ -340,6 +340,28 @@ func TestM4AMetadataAtomHelpers(t *testing.T) {
if quality, err := GetM4AQuality(aacQualityPath); err != nil || quality.BitDepth != 0 || quality.SampleRate != 44100 || quality.Duration != 180 { if quality, err := GetM4AQuality(aacQualityPath); err != nil || quality.BitDepth != 0 || quality.SampleRate != 44100 || quality.Duration != 180 {
t.Fatalf("GetM4AQuality AAC = %#v/%v", quality, err) t.Fatalf("GetM4AQuality AAC = %#v/%v", quality, err)
} }
eac3QualityPath := filepath.Join(dir, "quality-eac3.m4a")
zeroMvhd := make([]byte, 20)
eac3SampleEntry := make([]byte, 32)
copy(eac3SampleEntry[0:4], "ec-3")
eac3SampleEntry[28] = 0xBB
eac3SampleEntry[29] = 0x80
mdhd := make([]byte, 20)
binary.BigEndian.PutUint32(mdhd[12:16], 48000)
binary.BigEndian.PutUint32(mdhd[16:20], 48000*123)
eac3QualityFile := append(
buildM4AAtom("ftyp", []byte("M4A \x00\x00\x00\x00")),
buildM4AAtom("moov", append(
append(buildM4AAtom("mvhd", zeroMvhd), buildM4AAtom("trak", buildM4AAtom("mdia", buildM4AAtom("mdhd", mdhd)))...),
eac3SampleEntry...,
))...,
)
if err := os.WriteFile(eac3QualityPath, eac3QualityFile, 0600); err != nil {
t.Fatal(err)
}
if quality, err := GetM4AQuality(eac3QualityPath); err != nil || quality.Codec != "eac3" || quality.Duration != 123 {
t.Fatalf("GetM4AQuality EAC3 mdhd fallback = %#v/%v", quality, err)
}
if _, _, ok := parseALACSpecificConfig(make([]byte, 4)); ok { if _, _, ok := parseALACSpecificConfig(make([]byte, 4)); ok {
t.Fatal("short ALAC config should not parse") t.Fatal("short ALAC config should not parse")
} }

View file

@ -1423,6 +1423,19 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
isM4AFile := strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".mp4") || strings.HasSuffix(lower, ".m4b") isM4AFile := strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".mp4") || strings.HasSuffix(lower, ".m4b")
coverPath := strings.TrimSpace(fields["cover_path"]) coverPath := strings.TrimSpace(fields["cover_path"])
if hasOnlyM4AReplayGainFields(fields) && (isM4AFile || isMP4ContainerFile(filePath)) {
if err := EditM4AReplayGain(filePath, fields); err != nil {
return "", fmt.Errorf("failed to write M4A metadata: %w", err)
}
resp := map[string]any{
"success": true,
"method": "native_m4a_replaygain",
}
jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil
}
if isFlac { if isFlac {
if err := EditFlacFields(filePath, fields); err != nil { if err := EditFlacFields(filePath, fields); err != nil {
return "", fmt.Errorf("failed to write FLAC metadata: %w", err) return "", fmt.Errorf("failed to write FLAC metadata: %w", err)
@ -1533,19 +1546,6 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
if isM4AFile && hasOnlyM4AReplayGainFields(fields) {
if err := EditM4AReplayGain(filePath, fields); err != nil {
return "", fmt.Errorf("failed to write M4A metadata: %w", err)
}
resp := map[string]any{
"success": true,
"method": "native_m4a_replaygain",
}
jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil
}
resp := map[string]any{ resp := map[string]any{
"success": true, "success": true,
"method": "ffmpeg", "method": "ffmpeg",
@ -1555,6 +1555,21 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
func isMP4ContainerFile(filePath string) bool {
f, err := os.Open(filePath)
if err != nil {
return false
}
defer f.Close()
header := make([]byte, 12)
n, err := f.Read(header)
if err != nil || n < 8 {
return false
}
return string(header[4:8]) == "ftyp"
}
func hasOnlyM4AReplayGainFields(fields map[string]string) bool { func hasOnlyM4AReplayGainFields(fields map[string]string) bool {
allowed := map[string]struct{}{ allowed := map[string]struct{}{
"replaygain_track_gain": {}, "replaygain_track_gain": {},

View file

@ -85,6 +85,14 @@ func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
if response, err := EditFileMetadata(filepath.Join(dir, "edit.mp3"), editJSON); err != nil || !strings.Contains(response, "ffmpeg") { if response, err := EditFileMetadata(filepath.Join(dir, "edit.mp3"), editJSON); err != nil || !strings.Contains(response, "ffmpeg") {
t.Fatalf("EditFileMetadata ffmpeg = %q/%v", response, err) t.Fatalf("EditFileMetadata ffmpeg = %q/%v", response, err)
} }
misnamedM4APath := filepath.Join(dir, "misnamed.flac")
if err := os.WriteFile(misnamedM4APath, buildM4AFileWithIlst(buildM4ATextTag("\xa9nam", "Misnamed"), true), 0600); err != nil {
t.Fatal(err)
}
replayGainJSON := `{"replaygain_track_gain":"-1 dB","replaygain_track_peak":"0.9"}`
if response, err := EditFileMetadata(misnamedM4APath, replayGainJSON); err != nil || !strings.Contains(response, "native_m4a_replaygain") {
t.Fatalf("EditFileMetadata misnamed m4a replaygain = %q/%v", response, err)
}
if _, err := EditFileMetadata(apePath, `not-json`); err == nil { if _, err := EditFileMetadata(apePath, `not-json`); err == nil {
t.Fatal("expected invalid metadata JSON") t.Fatal("expected invalid metadata JSON")
} }

View file

@ -1578,11 +1578,11 @@ func looksLikeEmbeddedLyrics(value string) bool {
} }
type AudioQuality struct { type AudioQuality struct {
BitDepth int `json:"bit_depth"` BitDepth int `json:"bit_depth"`
SampleRate int `json:"sample_rate"` SampleRate int `json:"sample_rate"`
TotalSamples int64 `json:"total_samples"` TotalSamples int64 `json:"total_samples"`
Duration int `json:"duration"` Duration int `json:"duration"`
Bitrate int `json:"bitrate,omitempty"` // kbps, estimated for compressed MP4-family streams Bitrate int `json:"bitrate,omitempty"` // kbps, estimated for compressed MP4-family streams
Codec string `json:"codec,omitempty"` Codec string `json:"codec,omitempty"`
} }
@ -1727,6 +1727,9 @@ func GetM4AQuality(filePath string) (AudioQuality, error) {
} }
bitrate := estimateAudioBitrateKbps(fileSize, duration) bitrate := estimateAudioBitrateKbps(fileSize, duration)
if bitrate > 0 && bitrate < 16 {
bitrate = 0
}
return AudioQuality{ return AudioQuality{
BitDepth: bitDepth, BitDepth: bitDepth,
SampleRate: sampleRate, SampleRate: sampleRate,
@ -1766,11 +1769,17 @@ func readM4ADurationSeconds(f *os.File, moovHeader atomHeader, fileSize int64) i
childStart := moovHeader.offset + moovHeader.headerSize childStart := moovHeader.offset + moovHeader.headerSize
childSize := moovHeader.size - moovHeader.headerSize childSize := moovHeader.size - moovHeader.headerSize
mvhdHeader, found, err := findAtomInRange(f, childStart, childSize, "mvhd", fileSize) mvhdHeader, found, err := findAtomInRange(f, childStart, childSize, "mvhd", fileSize)
if err != nil || !found { if err == nil && found {
return 0 if duration := readMP4DurationAtomSeconds(f, mvhdHeader, fileSize); duration > 0 {
return duration
}
} }
payloadOffset := mvhdHeader.offset + mvhdHeader.headerSize return readM4ATrackDurationSeconds(f, moovHeader, fileSize)
}
func readMP4DurationAtomSeconds(f *os.File, header atomHeader, fileSize int64) int {
payloadOffset := header.offset + header.headerSize
versionBuf := make([]byte, 1) versionBuf := make([]byte, 1)
if _, err := f.ReadAt(versionBuf, payloadOffset); err != nil { if _, err := f.ReadAt(versionBuf, payloadOffset); err != nil {
return 0 return 0
@ -1801,6 +1810,53 @@ func readM4ADurationSeconds(f *os.File, moovHeader atomHeader, fileSize int64) i
return int(math.Round(float64(duration) / float64(timescale))) return int(math.Round(float64(duration) / float64(timescale)))
} }
func readM4ATrackDurationSeconds(f *os.File, moovHeader atomHeader, fileSize int64) int {
childStart := moovHeader.offset + moovHeader.headerSize
childSize := moovHeader.size - moovHeader.headerSize
bestDuration := 0
_ = walkMP4AtomsInRange(f, childStart, childSize, fileSize, func(header atomHeader) bool {
if header.typ == "mdhd" {
if duration := readMP4DurationAtomSeconds(f, header, fileSize); duration > bestDuration {
bestDuration = duration
}
return false
}
return header.typ == "trak" || header.typ == "mdia"
})
return bestDuration
}
func walkMP4AtomsInRange(f *os.File, start, size, fileSize int64, visit func(atomHeader) bool) error {
if size <= 0 {
return nil
}
end := start + size
for pos := start; pos+8 <= end; {
header, err := readAtomHeaderAt(f, pos, fileSize)
if err != nil {
return err
}
atomSize := header.size
if atomSize == 0 {
atomSize = end - pos
}
if atomSize < header.headerSize {
return fmt.Errorf("invalid atom size for %s", header.typ)
}
header.size = atomSize
if visit(header) {
childStart := header.offset + header.headerSize
childSize := header.size - header.headerSize
if err := walkMP4AtomsInRange(f, childStart, childSize, fileSize, visit); err != nil {
return err
}
}
pos += atomSize
}
return nil
}
func readALACSpecificConfig(f *os.File, sampleOffset, fileSize int64) (int, int, bool) { func readALACSpecificConfig(f *os.File, sampleOffset, fileSize int64) (int, int, bool) {
if sampleOffset < 4 { if sampleOffset < 4 {
return 0, 0, false return 0, 0, false

View file

@ -38,7 +38,8 @@ final _multiUnderscoreRegex = RegExp(r'_+');
int? _readPositiveBitrateKbps(dynamic value) { int? _readPositiveBitrateKbps(dynamic value) {
final parsed = readPositiveInt(value); final parsed = readPositiveInt(value);
if (parsed == null) return null; if (parsed == null) return null;
return parsed >= 10000 ? (parsed / 1000).round() : parsed; final kbps = parsed >= 10000 ? (parsed / 1000).round() : parsed;
return kbps >= 16 ? kbps : null;
} }
String? _audioFormatForPath(String? filePath, {String? fileName}) { String? _audioFormatForPath(String? filePath, {String? fileName}) {
@ -58,6 +59,14 @@ String? _nonPlaceholderQuality(String? quality) {
if (normalized == null || isPlaceholderQualityLabel(normalized)) { if (normalized == null || isPlaceholderQualityLabel(normalized)) {
return null; return null;
} }
final bitrateMatch = RegExp(
r'\b(\d+)\s*kbps\b',
caseSensitive: false,
).firstMatch(normalized);
if (bitrateMatch != null) {
final bitrate = int.tryParse(bitrateMatch.group(1) ?? '');
if (bitrate != null && bitrate < 16) return null;
}
final lower = normalized.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]+'), '_'); final lower = normalized.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]+'), '_');
const requestedLosslessLabels = { const requestedLosslessLabels = {
'hi_res_lossless', 'hi_res_lossless',
@ -70,6 +79,36 @@ String? _nonPlaceholderQuality(String? quality) {
return normalized; return normalized;
} }
String? _normalizeAudioFormatValue(String? value) {
final normalized = normalizeOptionalString(
value,
)?.toLowerCase().replaceAll('-', '_');
return switch (normalized) {
'flac' => 'flac',
'alac' => 'alac',
'aac' || 'mp4a' => 'aac',
'eac3' || 'ec_3' => 'eac3',
'ac3' || 'ac_3' => 'ac3',
'ac4' || 'ac_4' => 'ac4',
'mp3' => 'mp3',
'opus' || 'ogg' => 'opus',
'm4a' || 'mp4' => 'm4a',
_ => null,
};
}
bool _isLossyAudioFormat(String? value) {
return const {
'aac',
'eac3',
'ac3',
'ac4',
'mp3',
'opus',
'm4a',
}.contains(_normalizeAudioFormatValue(value));
}
String? _resolveDisplayQuality({ String? _resolveDisplayQuality({
required String? filePath, required String? filePath,
String? fileName, String? fileName,
@ -151,6 +190,8 @@ class DownloadHistoryItem {
final String? quality; final String? quality;
final int? bitDepth; final int? bitDepth;
final int? sampleRate; final int? sampleRate;
final int? bitrate;
final String? format;
final String? genre; final String? genre;
final String? composer; final String? composer;
final String? label; final String? label;
@ -182,6 +223,8 @@ class DownloadHistoryItem {
this.quality, this.quality,
this.bitDepth, this.bitDepth,
this.sampleRate, this.sampleRate,
this.bitrate,
this.format,
this.genre, this.genre,
this.composer, this.composer,
this.label, this.label,
@ -214,6 +257,8 @@ class DownloadHistoryItem {
'quality': quality, 'quality': quality,
'bitDepth': bitDepth, 'bitDepth': bitDepth,
'sampleRate': sampleRate, 'sampleRate': sampleRate,
'bitrate': bitrate,
'format': format,
'genre': genre, 'genre': genre,
'composer': composer, 'composer': composer,
'label': label, 'label': label,
@ -247,6 +292,8 @@ class DownloadHistoryItem {
quality: json['quality'] as String?, quality: json['quality'] as String?,
bitDepth: json['bitDepth'] as int?, bitDepth: json['bitDepth'] as int?,
sampleRate: json['sampleRate'] as int?, sampleRate: json['sampleRate'] as int?,
bitrate: (json['bitrate'] as num?)?.toInt(),
format: json['format'] as String?,
genre: json['genre'] as String?, genre: json['genre'] as String?,
composer: json['composer'] as String?, composer: json['composer'] as String?,
label: json['label'] as String?, label: json['label'] as String?,
@ -276,6 +323,8 @@ class DownloadHistoryItem {
String? quality, String? quality,
int? bitDepth, int? bitDepth,
int? sampleRate, int? sampleRate,
int? bitrate,
String? format,
String? genre, String? genre,
String? composer, String? composer,
String? label, String? label,
@ -307,6 +356,8 @@ class DownloadHistoryItem {
quality: quality ?? this.quality, quality: quality ?? this.quality,
bitDepth: bitDepth ?? this.bitDepth, bitDepth: bitDepth ?? this.bitDepth,
sampleRate: sampleRate ?? this.sampleRate, sampleRate: sampleRate ?? this.sampleRate,
bitrate: bitrate ?? this.bitrate,
format: format ?? this.format,
genre: genre ?? this.genre, genre: genre ?? this.genre,
composer: composer ?? this.composer, composer: composer ?? this.composer,
label: label ?? this.label, label: label ?? this.label,
@ -703,6 +754,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
item.bitDepth! > 0 && item.bitDepth! > 0 &&
item.sampleRate != null && item.sampleRate != null &&
item.sampleRate! > 0; item.sampleRate! > 0;
final needsFormatBackfill = normalizeOptionalString(item.format) == null;
final needsLosslessSpecProbe = final needsLosslessSpecProbe =
!hasResolvedSpecs && !hasResolvedSpecs &&
(trimmedPath.endsWith('.flac') || (trimmedPath.endsWith('.flac') ||
@ -720,6 +772,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
final needsDiscNumberBackfill = item.discNumber == null; final needsDiscNumberBackfill = item.discNumber == null;
final needsTotalDiscsBackfill = item.totalDiscs == null; final needsTotalDiscsBackfill = item.totalDiscs == null;
return needsComposerBackfill || return needsComposerBackfill ||
needsFormatBackfill ||
needsDurationBackfill || needsDurationBackfill ||
needsTrackNumberBackfill || needsTrackNumberBackfill ||
needsTotalTracksBackfill || needsTotalTracksBackfill ||
@ -735,6 +788,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
final needsDiscNumberBackfill = item.discNumber == null; final needsDiscNumberBackfill = item.discNumber == null;
final needsTotalDiscsBackfill = item.totalDiscs == null; final needsTotalDiscsBackfill = item.totalDiscs == null;
return needsLosslessSpecProbe || return needsLosslessSpecProbe ||
needsFormatBackfill ||
isPlaceholderQualityLabel(item.quality) || isPlaceholderQualityLabel(item.quality) ||
normalizeOptionalString(item.quality) == null || normalizeOptionalString(item.quality) == null ||
needsComposerBackfill || needsComposerBackfill ||
@ -761,11 +815,16 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
final bitDepth = readPositiveInt(result['bit_depth']); final bitDepth = readPositiveInt(result['bit_depth']);
final sampleRate = readPositiveInt(result['sample_rate']); final sampleRate = readPositiveInt(result['sample_rate']);
final bitrateKbps = _readPositiveBitrateKbps(result['bitrate']); final detectedFormat = _normalizeAudioFormatValue(
result['audio_codec']?.toString() ?? result['format']?.toString(),
);
final rawBitrateKbps = _readPositiveBitrateKbps(result['bitrate']);
final bitrateKbps = _isLossyAudioFormat(detectedFormat)
? rawBitrateKbps
: null;
final quality = _resolveDisplayQuality( final quality = _resolveDisplayQuality(
filePath: filePath, filePath: filePath,
detectedFormat: detectedFormat: detectedFormat,
result['audio_codec']?.toString() ?? result['format']?.toString(),
bitDepth: bitDepth, bitDepth: bitDepth,
sampleRate: sampleRate, sampleRate: sampleRate,
bitrateKbps: bitrateKbps, bitrateKbps: bitrateKbps,
@ -782,6 +841,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
bitDepth == null && bitDepth == null &&
sampleRate == null && sampleRate == null &&
bitrateKbps == null && bitrateKbps == null &&
detectedFormat == null &&
composer == null && composer == null &&
duration == null && duration == null &&
trackNumber == null && trackNumber == null &&
@ -795,6 +855,8 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
'quality': quality, 'quality': quality,
'bitDepth': bitDepth, 'bitDepth': bitDepth,
'sampleRate': sampleRate, 'sampleRate': sampleRate,
'bitrate': bitrateKbps,
'format': detectedFormat,
'bitrateKbps': bitrateKbps, 'bitrateKbps': bitrateKbps,
'composer': composer, 'composer': composer,
'duration': duration, 'duration': duration,
@ -868,6 +930,10 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
); );
final resolvedBitDepth = probed['bitDepth'] as int?; final resolvedBitDepth = probed['bitDepth'] as int?;
final resolvedSampleRate = probed['sampleRate'] as int?; final resolvedSampleRate = probed['sampleRate'] as int?;
final resolvedBitrate = probed['bitrate'] as int?;
final resolvedFormat = normalizeOptionalString(
probed['format'] as String?,
);
final resolvedComposer = normalizeOptionalString( final resolvedComposer = normalizeOptionalString(
probed['composer'] as String?, probed['composer'] as String?,
); );
@ -883,6 +949,10 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
resolvedBitDepth != null && resolvedBitDepth != item.bitDepth; resolvedBitDepth != null && resolvedBitDepth != item.bitDepth;
final sampleRateChanged = final sampleRateChanged =
resolvedSampleRate != null && resolvedSampleRate != item.sampleRate; resolvedSampleRate != null && resolvedSampleRate != item.sampleRate;
final bitrateChanged =
resolvedBitrate != null && resolvedBitrate != item.bitrate;
final formatChanged =
resolvedFormat != null && resolvedFormat != item.format;
final composerChanged = final composerChanged =
resolvedComposer != null && resolvedComposer != item.composer; resolvedComposer != null && resolvedComposer != item.composer;
final durationChanged = final durationChanged =
@ -901,6 +971,8 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
if (!qualityChanged && if (!qualityChanged &&
!bitDepthChanged && !bitDepthChanged &&
!sampleRateChanged && !sampleRateChanged &&
!bitrateChanged &&
!formatChanged &&
!composerChanged && !composerChanged &&
!durationChanged && !durationChanged &&
!trackNumberChanged && !trackNumberChanged &&
@ -914,6 +986,8 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
quality: resolvedQuality, quality: resolvedQuality,
bitDepth: resolvedBitDepth, bitDepth: resolvedBitDepth,
sampleRate: resolvedSampleRate, sampleRate: resolvedSampleRate,
bitrate: resolvedBitrate,
format: resolvedFormat,
composer: resolvedComposer, composer: resolvedComposer,
duration: resolvedDuration, duration: resolvedDuration,
trackNumber: resolvedTrackNumber, trackNumber: resolvedTrackNumber,
@ -1197,6 +1271,8 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
String? quality, String? quality,
int? bitDepth, int? bitDepth,
int? sampleRate, int? sampleRate,
int? bitrate,
String? format,
int? trackNumber, int? trackNumber,
int? totalTracks, int? totalTracks,
int? discNumber, int? discNumber,
@ -1217,6 +1293,8 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
quality: quality, quality: quality,
bitDepth: bitDepth, bitDepth: bitDepth,
sampleRate: sampleRate, sampleRate: sampleRate,
bitrate: bitrate,
format: format,
trackNumber: trackNumber, trackNumber: trackNumber,
totalTracks: totalTracks, totalTracks: totalTracks,
discNumber: discNumber, discNumber: discNumber,
@ -1228,6 +1306,8 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
if (updated.quality == current.quality && if (updated.quality == current.quality &&
updated.bitDepth == current.bitDepth && updated.bitDepth == current.bitDepth &&
updated.sampleRate == current.sampleRate && updated.sampleRate == current.sampleRate &&
updated.bitrate == current.bitrate &&
updated.format == current.format &&
updated.trackNumber == current.trackNumber && updated.trackNumber == current.trackNumber &&
updated.totalTracks == current.totalTracks && updated.totalTracks == current.totalTracks &&
updated.discNumber == current.discNumber && updated.discNumber == current.discNumber &&
@ -5706,10 +5786,22 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
var actualQuality = context.quality; var actualQuality = context.quality;
final actualBitDepth = result['actual_bit_depth'] as int?; final actualBitDepth = result['actual_bit_depth'] as int?;
final actualSampleRate = result['actual_sample_rate'] as int?; final actualSampleRate = result['actual_sample_rate'] as int?;
final actualFormat =
_normalizeAudioFormatValue(
result['audio_codec']?.toString() ?? result['format']?.toString(),
) ??
_normalizeAudioFormatValue(_audioFormatForPath(filePath));
final actualBitrate = _isLossyAudioFormat(actualFormat)
? _readPositiveBitrateKbps(
result['bitrate'] ?? result['actual_bitrate'],
)
: null;
final resolvedQuality = _resolveDisplayQuality( final resolvedQuality = _resolveDisplayQuality(
filePath: filePath, filePath: filePath,
detectedFormat: actualFormat,
bitDepth: actualBitDepth, bitDepth: actualBitDepth,
sampleRate: actualSampleRate, sampleRate: actualSampleRate,
bitrateKbps: actualBitrate,
storedQuality: actualQuality, storedQuality: actualQuality,
); );
if (resolvedQuality != null) { if (resolvedQuality != null) {
@ -5818,7 +5910,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final backendComposer = result['composer'] as String?; final backendComposer = result['composer'] as String?;
final resultSafFileName = result['file_name'] as String?; final resultSafFileName = result['file_name'] as String?;
final lowerFilePath = filePath.toLowerCase(); final lowerFilePath = filePath.toLowerCase();
final historyFormat =
_normalizeAudioFormatValue(
result['audio_codec']?.toString() ?? result['format']?.toString(),
) ??
_normalizeAudioFormatValue(_audioFormatForPath(filePath));
final isLossyOutput = final isLossyOutput =
_isLossyAudioFormat(historyFormat) ||
lowerFilePath.endsWith('.mp3') || lowerFilePath.endsWith('.mp3') ||
lowerFilePath.endsWith('.opus') || lowerFilePath.endsWith('.opus') ||
lowerFilePath.endsWith('.ogg'); lowerFilePath.endsWith('.ogg');
@ -5896,6 +5994,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
quality: actualQuality, quality: actualQuality,
bitDepth: isLossyOutput ? null : actualBitDepth, bitDepth: isLossyOutput ? null : actualBitDepth,
sampleRate: isLossyOutput ? null : actualSampleRate, sampleRate: isLossyOutput ? null : actualSampleRate,
bitrate: isLossyOutput ? actualBitrate : null,
format: historyFormat,
genre: normalizeOptionalString(backendGenre), genre: normalizeOptionalString(backendGenre),
composer: historyComposer, composer: historyComposer,
label: normalizeOptionalString(backendLabel), label: normalizeOptionalString(backendLabel),
@ -8192,6 +8292,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final backendTotalDiscs = _parsePositiveInt(result['total_discs']); final backendTotalDiscs = _parsePositiveInt(result['total_discs']);
final backendBitDepth = result['actual_bit_depth'] as int?; final backendBitDepth = result['actual_bit_depth'] as int?;
final backendSampleRate = result['actual_sample_rate'] as int?; final backendSampleRate = result['actual_sample_rate'] as int?;
final backendFormat =
_normalizeAudioFormatValue(
result['audio_codec']?.toString() ??
result['format']?.toString(),
) ??
_normalizeAudioFormatValue(_audioFormatForPath(filePath));
final backendBitrateKbps = _readPositiveBitrateKbps( final backendBitrateKbps = _readPositiveBitrateKbps(
result['bitrate'] ?? result['actual_bitrate'], result['bitrate'] ?? result['actual_bitrate'],
); );
@ -8215,7 +8321,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
int? finalBitDepth = backendBitDepth; int? finalBitDepth = backendBitDepth;
int? finalSampleRate = backendSampleRate; int? finalSampleRate = backendSampleRate;
int? finalBitrateKbps = backendBitrateKbps; String? finalFormat = backendFormat;
int? finalBitrateKbps = _isLossyAudioFormat(finalFormat)
? backendBitrateKbps
: null;
final lowerFilePath = filePath.toLowerCase(); final lowerFilePath = filePath.toLowerCase();
final canProbeFinalMetadata = final canProbeFinalMetadata =
filePath.startsWith('content://') || filePath.startsWith('content://') ||
@ -8244,16 +8353,25 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (probedSampleRate != null && probedSampleRate > 0) { if (probedSampleRate != null && probedSampleRate > 0) {
finalSampleRate = probedSampleRate; finalSampleRate = probedSampleRate;
} }
final probedFormat = _normalizeAudioFormatValue(
metadata['audio_codec']?.toString() ??
metadata['format']?.toString(),
);
if (probedFormat != null) {
finalFormat = probedFormat;
}
final probedBitrateKbps = _readPositiveBitrateKbps( final probedBitrateKbps = _readPositiveBitrateKbps(
metadata['bitrate'] ?? metadata['bit_rate'], metadata['bitrate'] ?? metadata['bit_rate'],
); );
if (probedBitrateKbps != null && probedBitrateKbps > 0) { if (probedBitrateKbps != null &&
_isLossyAudioFormat(finalFormat)) {
finalBitrateKbps = probedBitrateKbps; finalBitrateKbps = probedBitrateKbps;
} }
final resolvedQuality = _resolveDisplayQuality( final resolvedQuality = _resolveDisplayQuality(
filePath: filePath, filePath: filePath,
fileName: finalSafFileName, fileName: finalSafFileName,
detectedFormat: finalFormat,
bitDepth: finalBitDepth, bitDepth: finalBitDepth,
sampleRate: finalSampleRate, sampleRate: finalSampleRate,
bitrateKbps: finalBitrateKbps, bitrateKbps: finalBitrateKbps,
@ -8275,11 +8393,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
); );
final isLossyOutput = final isLossyOutput =
_isLossyAudioFormat(finalFormat) ||
lowerFilePath.endsWith('.mp3') || lowerFilePath.endsWith('.mp3') ||
lowerFilePath.endsWith('.opus') || lowerFilePath.endsWith('.opus') ||
lowerFilePath.endsWith('.ogg'); lowerFilePath.endsWith('.ogg');
final historyBitDepth = isLossyOutput ? null : finalBitDepth; final historyBitDepth = isLossyOutput ? null : finalBitDepth;
final historySampleRate = isLossyOutput ? null : finalSampleRate; final historySampleRate = isLossyOutput ? null : finalSampleRate;
final historyBitrate = isLossyOutput ? finalBitrateKbps : null;
final historyTotalTracks = _resolvePositiveMetadataInt( final historyTotalTracks = _resolvePositiveMetadataInt(
trackToDownload.totalTracks, trackToDownload.totalTracks,
backendTotalTracks, backendTotalTracks,
@ -8353,6 +8473,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
quality: actualQuality, quality: actualQuality,
bitDepth: historyBitDepth, bitDepth: historyBitDepth,
sampleRate: historySampleRate, sampleRate: historySampleRate,
bitrate: historyBitrate,
format: finalFormat,
genre: effectiveGenre, genre: effectiveGenre,
composer: historyComposer, composer: historyComposer,
label: effectiveLabel, label: effectiveLabel,

View file

@ -1491,6 +1491,10 @@ class _QueueTabState extends ConsumerState<QueueTab> {
if (localFormat != null) { if (localFormat != null) {
return localFormat.toLowerCase().replaceAll('-', '_'); return localFormat.toLowerCase().replaceAll('-', '_');
} }
final historyFormat = normalizeOptionalString(item.historyItem?.format);
if (historyFormat != null) {
return historyFormat.toLowerCase().replaceAll('-', '_');
}
return _fileExtLower(item.filePath); return _fileExtLower(item.filePath);
} }

View file

@ -33,6 +33,21 @@ class UnifiedLibraryItem {
}); });
factory UnifiedLibraryItem.fromDownloadHistory(DownloadHistoryItem item) { factory UnifiedLibraryItem.fromDownloadHistory(DownloadHistoryItem item) {
String? quality;
if (item.bitrate != null && item.bitrate! > 0) {
quality = buildDisplayAudioQuality(
bitrateKbps: item.bitrate,
format: item.format,
);
} else if (item.bitDepth != null &&
item.bitDepth! > 0 &&
item.sampleRate != null) {
quality = buildDisplayAudioQuality(
bitDepth: item.bitDepth,
sampleRate: item.sampleRate,
);
}
quality ??= item.quality;
return UnifiedLibraryItem( return UnifiedLibraryItem(
id: 'dl_${item.id}', id: 'dl_${item.id}',
trackName: item.trackName, trackName: item.trackName,
@ -40,11 +55,7 @@ class UnifiedLibraryItem {
albumName: item.albumName, albumName: item.albumName,
coverUrl: item.coverUrl, coverUrl: item.coverUrl,
filePath: item.filePath, filePath: item.filePath,
quality: buildDisplayAudioQuality( quality: quality,
bitDepth: item.bitDepth,
sampleRate: item.sampleRate,
storedQuality: item.quality,
),
addedAt: item.downloadedAt, addedAt: item.downloadedAt,
source: LibraryItemSource.downloaded, source: LibraryItemSource.downloaded,
historyItem: item, historyItem: item,

View file

@ -368,11 +368,21 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
final resolvedBitDepth = readPositiveInt(metadata['bit_depth']); final resolvedBitDepth = readPositiveInt(metadata['bit_depth']);
final resolvedSampleRate = readPositiveInt(metadata['sample_rate']); final resolvedSampleRate = readPositiveInt(metadata['sample_rate']);
final resolvedFormat = _normalizeAudioFormatValue(
metadata['audio_codec']?.toString() ?? metadata['format']?.toString(),
);
final resolvedBitrate = _isBitrateFormatValue(resolvedFormat)
? _readPlausibleBitrateKbps(
metadata['bitrate'] ?? metadata['bit_rate'],
)
: null;
final resolvedDuration = readPositiveInt(metadata['duration']); final resolvedDuration = readPositiveInt(metadata['duration']);
final resolvedAlbum = metadata['album']?.toString(); final resolvedAlbum = metadata['album']?.toString();
final resolvedQuality = buildDisplayAudioQuality( final resolvedQuality = _displayQualityForValues(
format: resolvedFormat ?? _storedAudioFormat,
bitDepth: resolvedBitDepth ?? bitDepth, bitDepth: resolvedBitDepth ?? bitDepth,
sampleRate: resolvedSampleRate ?? sampleRate, sampleRate: resolvedSampleRate ?? sampleRate,
bitrateKbps: resolvedBitrate ?? _audioBitrate,
storedQuality: _quality, storedQuality: _quality,
); );
@ -427,6 +437,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
!_isLocalItem && !_isLocalItem &&
(resolvedBitDepth != null || (resolvedBitDepth != null ||
resolvedSampleRate != null || resolvedSampleRate != null ||
resolvedBitrate != null ||
resolvedFormat != null ||
needsTrackNumber || needsTrackNumber ||
needsTotalTracks || needsTotalTracks ||
needsDiscNumber || needsDiscNumber ||
@ -476,6 +488,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
quality: resolvedQuality, quality: resolvedQuality,
bitDepth: resolvedBitDepth, bitDepth: resolvedBitDepth,
sampleRate: resolvedSampleRate, sampleRate: resolvedSampleRate,
bitrate: resolvedBitrate,
format: resolvedFormat,
trackNumber: needsTrackNumber ? resolvedTrackNumber : null, trackNumber: needsTrackNumber ? resolvedTrackNumber : null,
totalTracks: needsTotalTracks ? resolvedTotalTracks : null, totalTracks: needsTotalTracks ? resolvedTotalTracks : null,
discNumber: needsDiscNumber ? resolvedDiscNumber : null, discNumber: needsDiscNumber ? resolvedDiscNumber : null,
@ -483,6 +497,23 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
duration: needsDuration ? resolvedDuration : null, duration: needsDuration ? resolvedDuration : null,
composer: needsComposer ? resolvedComposer : null, composer: needsComposer ? resolvedComposer : null,
); );
if (mounted && _downloadItem != null) {
setState(() {
_currentDownloadItem = _downloadItem!.copyWith(
quality: resolvedQuality,
bitDepth: resolvedBitDepth,
sampleRate: resolvedSampleRate,
bitrate: resolvedBitrate,
format: resolvedFormat,
trackNumber: needsTrackNumber ? resolvedTrackNumber : null,
totalTracks: needsTotalTracks ? resolvedTotalTracks : null,
discNumber: needsDiscNumber ? resolvedDiscNumber : null,
totalDiscs: needsTotalDiscs ? resolvedTotalDiscs : null,
duration: needsDuration ? resolvedDuration : null,
composer: needsComposer ? resolvedComposer : null,
);
});
}
} else if (_isLocalItem && needsDuration) { } else if (_isLocalItem && needsDuration) {
await LibraryDatabase.instance.updateAudioMetadata( await LibraryDatabase.instance.updateAudioMetadata(
_localLibraryItem!.id, _localLibraryItem!.id,
@ -681,7 +712,10 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
(_isLocalItem (_isLocalItem
? _localLibraryItem!.sampleRate ? _localLibraryItem!.sampleRate
: _downloadItem!.sampleRate); : _downloadItem!.sampleRate);
int? get _localBitrate => _isLocalItem ? _localLibraryItem!.bitrate : null; int? get _audioBitrate =>
_isLocalItem ? _localLibraryItem!.bitrate : _downloadItem?.bitrate;
String? get _storedAudioFormat =>
_isLocalItem ? _localLibraryItem?.format : _downloadItem?.format;
String get _filePath => String get _filePath =>
_isLocalItem ? _localLibraryItem!.filePath : _downloadItem!.filePath; _isLocalItem ? _localLibraryItem!.filePath : _downloadItem!.filePath;
@ -707,6 +741,85 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
String? get _quality => _isLocalItem ? null : _downloadItem!.quality; String? get _quality => _isLocalItem ? null : _downloadItem!.quality;
String? _normalizeAudioFormatValue(String? value) {
final normalized = normalizeOptionalString(
value,
)?.toLowerCase().replaceAll('-', '_');
return switch (normalized) {
'flac' => 'flac',
'alac' => 'alac',
'aac' || 'mp4a' => 'aac',
'eac3' || 'ec_3' => 'eac3',
'ac3' || 'ac_3' => 'ac3',
'ac4' || 'ac_4' => 'ac4',
'mp3' => 'mp3',
'opus' || 'ogg' => 'opus',
'm4a' || 'mp4' => 'm4a',
_ => null,
};
}
int? _readPlausibleBitrateKbps(dynamic value) {
final parsed = readPositiveInt(value);
if (parsed == null) return null;
final kbps = parsed >= 10000 ? (parsed / 1000).round() : parsed;
return kbps >= 16 ? kbps : null;
}
bool _isBitrateFormatValue(String? value) {
return const {
'aac',
'eac3',
'ac3',
'ac4',
'mp3',
'opus',
'm4a',
}.contains(_normalizeAudioFormatValue(value));
}
String? _usableStoredQuality(String? quality) {
final normalized = normalizeOptionalString(quality);
if (normalized == null || isPlaceholderQualityLabel(normalized)) {
return null;
}
final bitrateMatch = RegExp(
r'\b(\d+)\s*kbps\b',
caseSensitive: false,
).firstMatch(normalized);
if (bitrateMatch != null) {
final bitrate = int.tryParse(bitrateMatch.group(1) ?? '');
if (bitrate != null && bitrate < 16) return null;
}
return normalized;
}
String? _displayQualityForValues({
required String? format,
int? bitDepth,
int? sampleRate,
int? bitrateKbps,
String? storedQuality,
}) {
final normalizedFormat = _normalizeAudioFormatValue(format);
final formatLabel = normalizedFormat == null
? normalizeOptionalString(format)?.toUpperCase()
: _formatLabelForRaw(normalizedFormat);
if (_isBitrateFormatValue(normalizedFormat)) {
return buildDisplayAudioQuality(
bitrateKbps: bitrateKbps,
format: formatLabel,
) ??
_usableStoredQuality(storedQuality) ??
formatLabel;
}
return buildDisplayAudioQuality(
bitDepth: bitDepth,
sampleRate: sampleRate,
storedQuality: _usableStoredQuality(storedQuality),
);
}
String _displayServiceTrackId(String value) { String _displayServiceTrackId(String value) {
final raw = value.trim(); final raw = value.trim();
if (raw.isEmpty) return raw; if (raw.isEmpty) return raw;
@ -766,11 +879,11 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
? fileName.split('.').last.toUpperCase() ? fileName.split('.').last.toUpperCase()
: null; : null;
return buildDisplayAudioQuality( return _displayQualityForValues(
format: _storedAudioFormat ?? fileExt,
bitDepth: bitDepth, bitDepth: bitDepth,
sampleRate: sampleRate, sampleRate: sampleRate,
bitrateKbps: _isLocalItem ? _localBitrate : null, bitrateKbps: _audioBitrate,
format: _isLocalItem ? (_localLibraryItem!.format ?? fileExt) : fileExt,
storedQuality: _quality, storedQuality: _quality,
); );
} }
@ -1611,13 +1724,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
return '$minutes:${secs.toString().padLeft(2, '0')}'; return '$minutes:${secs.toString().padLeft(2, '0')}';
} }
String _displayFormatLabelForFile(String fileName) { String _formatLabelForRaw(String raw) {
final localFormat = _isLocalItem
? normalizeOptionalString(_localLibraryItem?.format)
: null;
final raw =
localFormat ??
(fileName.contains('.') ? fileName.split('.').last : 'Unknown');
final normalized = raw.toLowerCase().replaceAll('-', '_'); final normalized = raw.toLowerCase().replaceAll('-', '_');
return switch (normalized) { return switch (normalized) {
'flac' => 'FLAC', 'flac' => 'FLAC',
@ -1626,7 +1733,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
'ac3' || 'ac_3' => 'AC3', 'ac3' || 'ac_3' => 'AC3',
'ac4' || 'ac_4' => 'AC4', 'ac4' || 'ac_4' => 'AC4',
'aac' || 'mp4a' => 'AAC', 'aac' || 'mp4a' => 'AAC',
'm4a' => 'M4A', 'm4a' || 'mp4' => 'M4A',
'mp3' => 'MP3', 'mp3' => 'MP3',
'opus' => 'Opus', 'opus' => 'Opus',
'ogg' => 'OGG', 'ogg' => 'OGG',
@ -1634,6 +1741,14 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}; };
} }
String _displayFormatLabelForFile(String fileName) {
final storedFormat = normalizeOptionalString(_storedAudioFormat);
final raw =
storedFormat ??
(fileName.contains('.') ? fileName.split('.').last : 'Unknown');
return _formatLabelForRaw(raw);
}
bool _isBitrateFormatLabel(String label) { bool _isBitrateFormatLabel(String label) {
return const { return const {
'MP3', 'MP3',
@ -1748,9 +1863,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
), ),
), ),
) )
else if (_isLocalItem && else if (_audioBitrate != null &&
_localBitrate != null && _audioBitrate! > 0 &&
_localBitrate! > 0 &&
_isBitrateFormatLabel(fileExtension)) _isBitrateFormatLabel(fileExtension))
Container( Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
@ -1762,7 +1876,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
), ),
child: Text( child: Text(
'${_localBitrate}kbps', '${_audioBitrate}kbps',
style: TextStyle( style: TextStyle(
color: colorScheme.onTertiaryContainer, color: colorScheme.onTertiaryContainer,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,

View file

@ -84,7 +84,7 @@ class HistoryDatabase {
return await openDatabase( return await openDatabase(
path, path,
version: 8, version: 9,
onConfigure: (db) async { onConfigure: (db) async {
await db.rawQuery('PRAGMA journal_mode = WAL'); await db.rawQuery('PRAGMA journal_mode = WAL');
await db.execute('PRAGMA synchronous = NORMAL'); await db.execute('PRAGMA synchronous = NORMAL');
@ -124,6 +124,8 @@ class HistoryDatabase {
quality TEXT, quality TEXT,
bit_depth INTEGER, bit_depth INTEGER,
sample_rate INTEGER, sample_rate INTEGER,
bitrate INTEGER,
format TEXT,
genre TEXT, genre TEXT,
composer TEXT, composer TEXT,
label TEXT, label TEXT,
@ -203,6 +205,10 @@ class HistoryDatabase {
await _backfillNormalizedColumns(db); await _backfillNormalizedColumns(db);
await _createNormalizedIndexes(db); await _createNormalizedIndexes(db);
} }
if (oldVersion < 9) {
await _addColumnIfMissing(db, 'history', 'bitrate', 'INTEGER');
await _addColumnIfMissing(db, 'history', 'format', 'TEXT');
}
} }
static String normalizeLookupText(String? value) { static String normalizeLookupText(String? value) {
@ -507,6 +513,8 @@ class HistoryDatabase {
'quality': json['quality'], 'quality': json['quality'],
'bit_depth': json['bitDepth'], 'bit_depth': json['bitDepth'],
'sample_rate': json['sampleRate'], 'sample_rate': json['sampleRate'],
'bitrate': json['bitrate'],
'format': json['format'],
'genre': json['genre'], 'genre': json['genre'],
'composer': json['composer'], 'composer': json['composer'],
'label': json['label'], 'label': json['label'],
@ -550,6 +558,8 @@ class HistoryDatabase {
'quality': row['quality'], 'quality': row['quality'],
'bitDepth': row['bit_depth'], 'bitDepth': row['bit_depth'],
'sampleRate': row['sample_rate'], 'sampleRate': row['sample_rate'],
'bitrate': row['bitrate'],
'format': row['format'],
'genre': row['genre'], 'genre': row['genre'],
'composer': row['composer'], 'composer': row['composer'],
'label': row['label'], 'label': row['label'],

View file

@ -1029,8 +1029,8 @@ class LibraryDatabase {
NULL AS cover_path, NULL AS cover_path,
NULL AS scanned_at, NULL AS scanned_at,
NULL AS file_mod_time, NULL AS file_mod_time,
NULL AS bitrate, h.bitrate,
NULL AS format, h.format,
LOWER(h.track_name) AS sort_track, LOWER(h.track_name) AS sort_track,
LOWER(h.artist_name) AS sort_artist, LOWER(h.artist_name) AS sort_artist,
LOWER(h.album_name) AS sort_album, LOWER(h.album_name) AS sort_album,
@ -1299,7 +1299,7 @@ class LibraryDatabase {
args, args,
request, request,
filePathExpr: 'h.file_path', filePathExpr: 'h.file_path',
formatExpr: null, formatExpr: 'h.format',
qualityExpr: 'h.quality', qualityExpr: 'h.quality',
bitDepthExpr: 'h.bit_depth', bitDepthExpr: 'h.bit_depth',
artistExpr: 'h.artist_name', artistExpr: 'h.artist_name',
@ -1544,6 +1544,8 @@ class LibraryDatabase {
'quality': row['quality'], 'quality': row['quality'],
'bitDepth': row['bit_depth'], 'bitDepth': row['bit_depth'],
'sampleRate': row['sample_rate'], 'sampleRate': row['sample_rate'],
'bitrate': row['bitrate'],
'format': row['format'],
'genre': row['genre'], 'genre': row['genre'],
'composer': row['composer'], 'composer': row['composer'],
'label': row['label'], 'label': row['label'],