mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-03-31 00:39:24 +02:00
fix: Samsung SAF library scan, Qobuz album cover, M4A metadata save and log improvements
- Fix M4A/ALAC scan silently failing on Samsung by adding proper fallback to scanFromFilename when ReadM4ATags fails (consistent with MP3/FLAC/Ogg) - Propagate displayNameHint to all format scanners so fd numbers (214, 207) no longer appear as track names when /proc/self/fd/ paths are used - Cache /proc/self/fd/ readability in Kotlin to skip failed attempts after first failure, reducing error log noise and improving scan speed on Samsung - Fix Qobuz download returning wrong album cover when track exists on multiple albums by preferring req.CoverURL over API default - Fix FFmpeg M4A metadata save failing with 'codec not currently supported in container' by forcing mp4 muxer instead of ipod when cover art present - Clean up FLAC SAF temp file after metadata write-back (was leaking) - Update LRC lyrics tag to credit Paxsenix API - Remove log message truncation, defer to UI preview truncation instead
This commit is contained in:
@@ -56,6 +56,8 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
private var flutterBackCallback: OnBackPressedCallback? = null
|
||||
@Volatile private var safScanCancel = false
|
||||
@Volatile private var safScanActive = false
|
||||
/** Tri-state: null = untested, true = works, false = fails (Samsung SELinux). */
|
||||
@Volatile private var procSelfFdReadable: Boolean? = null
|
||||
private val safTreeLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) { activityResult ->
|
||||
@@ -375,6 +377,8 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
synchronized(safScanLock) {
|
||||
safScanProgress = SafScanProgress()
|
||||
}
|
||||
// Allow re-probing /proc/self/fd readability on every new scan session.
|
||||
procSelfFdReadable = null
|
||||
}
|
||||
|
||||
private fun updateSafScanProgress(block: (SafScanProgress) -> Unit) {
|
||||
@@ -804,27 +808,45 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
): JSONObject? {
|
||||
val displayName = buildUriDisplayName(uri, displayNameHint, fallbackExt)
|
||||
|
||||
try {
|
||||
contentResolver.openFileDescriptor(uri, "r")?.use { pfd ->
|
||||
val directPath = "/proc/self/fd/${pfd.fd}"
|
||||
val metadataJson = Gobackend.readAudioMetadataWithHintAndCoverCacheKeyJSON(
|
||||
directPath,
|
||||
displayName,
|
||||
coverCacheKey,
|
||||
)
|
||||
if (metadataJson.isNotBlank()) {
|
||||
val obj = JSONObject(metadataJson)
|
||||
val filenameFallback = obj.optBoolean("metadataFromFilename", false)
|
||||
if (!obj.has("error") && !filenameFallback) {
|
||||
return obj
|
||||
// Skip /proc/self/fd/ attempt when known to fail (e.g. Samsung SELinux).
|
||||
if (procSelfFdReadable != false) {
|
||||
try {
|
||||
contentResolver.openFileDescriptor(uri, "r")?.use { pfd ->
|
||||
val directPath = "/proc/self/fd/${pfd.fd}"
|
||||
val metadataJson = Gobackend.readAudioMetadataWithHintAndCoverCacheKeyJSON(
|
||||
directPath,
|
||||
displayName,
|
||||
coverCacheKey,
|
||||
)
|
||||
if (metadataJson.isNotBlank()) {
|
||||
val obj = JSONObject(metadataJson)
|
||||
val filenameFallback = obj.optBoolean("metadataFromFilename", false)
|
||||
if (!obj.has("error") && !filenameFallback) {
|
||||
procSelfFdReadable = true
|
||||
return obj
|
||||
}
|
||||
// Go could not read real metadata from the fd path –
|
||||
// remember so we skip the attempt for remaining files.
|
||||
if (procSelfFdReadable == null) {
|
||||
procSelfFdReadable = false
|
||||
android.util.Log.d(
|
||||
"SpotiFLAC",
|
||||
"Direct /proc/self/fd read not usable on this device, " +
|
||||
"using temp-file fallback for remaining files",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (procSelfFdReadable == null) {
|
||||
procSelfFdReadable = false
|
||||
android.util.Log.d(
|
||||
"SpotiFLAC",
|
||||
"Direct /proc/self/fd read not usable on this device, " +
|
||||
"using temp-file fallback for remaining files",
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.d(
|
||||
"SpotiFLAC",
|
||||
"Direct SAF metadata read fallback for $uri: ${e.message}",
|
||||
)
|
||||
}
|
||||
|
||||
val tempPath = try {
|
||||
@@ -2411,11 +2433,13 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
return@withContext obj.toString()
|
||||
// Note: temp file NOT deleted here - Dart will clean up after FFmpeg + writeTempToSaf
|
||||
}
|
||||
// FLAC: Go wrote directly to temp, copy back now
|
||||
if (!writeUriFromPath(uri, tempPath)) {
|
||||
return@withContext """{"error":"Failed to write metadata back to SAF file"}"""
|
||||
}
|
||||
raw
|
||||
// FLAC: Go wrote directly to temp, copy back now
|
||||
if (!writeUriFromPath(uri, tempPath)) {
|
||||
try { File(tempPath).delete() } catch (_: Exception) {}
|
||||
return@withContext """{"error":"Failed to write metadata back to SAF file"}"""
|
||||
}
|
||||
try { File(tempPath).delete() } catch (_: Exception) {}
|
||||
raw
|
||||
} catch (e: Exception) {
|
||||
try { File(tempPath).delete() } catch (_: Exception) {}
|
||||
throw e
|
||||
|
||||
@@ -311,11 +311,11 @@ func scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displ
|
||||
|
||||
switch ext {
|
||||
case ".flac":
|
||||
return scanFLACFile(filePath, result)
|
||||
return scanFLACFile(filePath, result, displayNameHint)
|
||||
case ".m4a":
|
||||
return scanM4AFile(filePath, result)
|
||||
return scanM4AFile(filePath, result, displayNameHint)
|
||||
case ".mp3":
|
||||
return scanMP3File(filePath, result)
|
||||
return scanMP3File(filePath, result, displayNameHint)
|
||||
case ".opus", ".ogg":
|
||||
return scanOggFile(filePath, result, displayNameHint)
|
||||
default:
|
||||
@@ -351,10 +351,10 @@ func applyDefaultLibraryMetadata(filePath, displayNameHint string, result *Libra
|
||||
}
|
||||
}
|
||||
|
||||
func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||
func scanFLACFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
|
||||
metadata, err := ReadMetadata(filePath)
|
||||
if err != nil {
|
||||
return scanFromFilename(filePath, "", result)
|
||||
return scanFromFilename(filePath, displayNameHint, result)
|
||||
}
|
||||
|
||||
result.TrackName = metadata.Title
|
||||
@@ -376,14 +376,19 @@ func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResul
|
||||
}
|
||||
}
|
||||
|
||||
applyDefaultLibraryMetadata(filePath, "", result)
|
||||
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func scanM4AFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||
func scanM4AFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
|
||||
metadata, err := ReadM4ATags(filePath)
|
||||
if err == nil && metadata != nil {
|
||||
if err != nil {
|
||||
GoLog("[LibraryScan] M4A read error for %s: %v\n", filePath, err)
|
||||
return scanFromFilename(filePath, displayNameHint, result)
|
||||
}
|
||||
|
||||
if metadata != nil {
|
||||
result.TrackName = metadata.Title
|
||||
result.ArtistName = metadata.Artist
|
||||
result.AlbumName = metadata.Album
|
||||
@@ -404,15 +409,15 @@ func scanM4AFile(filePath string, result *LibraryScanResult) (*LibraryScanResult
|
||||
result.SampleRate = quality.SampleRate
|
||||
}
|
||||
|
||||
applyDefaultLibraryMetadata(filePath, "", result)
|
||||
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||
func scanMP3File(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
|
||||
metadata, err := ReadID3Tags(filePath)
|
||||
if err != nil {
|
||||
GoLog("[LibraryScan] ID3 read error for %s: %v\n", filePath, err)
|
||||
return scanFromFilename(filePath, "", result)
|
||||
return scanFromFilename(filePath, displayNameHint, result)
|
||||
}
|
||||
|
||||
result.TrackName = metadata.Title
|
||||
@@ -439,7 +444,7 @@ func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult
|
||||
}
|
||||
}
|
||||
|
||||
applyDefaultLibraryMetadata(filePath, "", result)
|
||||
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ type LogBuffer struct {
|
||||
|
||||
const (
|
||||
defaultLogBufferSize = 500
|
||||
maxLogMessageLength = 500
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -58,14 +57,6 @@ func GetLogBuffer() *LogBuffer {
|
||||
return globalLogBuffer
|
||||
}
|
||||
|
||||
func truncateLogMessage(message string) string {
|
||||
runes := []rune(message)
|
||||
if len(runes) <= maxLogMessageLength {
|
||||
return message
|
||||
}
|
||||
return string(runes[:maxLogMessageLength]) + "...[truncated]"
|
||||
}
|
||||
|
||||
func (lb *LogBuffer) SetLoggingEnabled(enabled bool) {
|
||||
lb.mu.Lock()
|
||||
defer lb.mu.Unlock()
|
||||
@@ -87,7 +78,6 @@ func (lb *LogBuffer) Add(level, tag, message string) {
|
||||
}
|
||||
|
||||
message = sanitizeSensitiveLogText(message)
|
||||
message = truncateLogMessage(message)
|
||||
|
||||
entry := LogEntry{
|
||||
Timestamp: time.Now().Format("15:04:05.000"),
|
||||
|
||||
@@ -982,7 +982,7 @@ func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName stri
|
||||
|
||||
builder.WriteString(fmt.Sprintf("[ti:%s]\n", trackName))
|
||||
builder.WriteString(fmt.Sprintf("[ar:%s]\n", artistName))
|
||||
builder.WriteString("[by:SpotiFLAC-Mobile]\n")
|
||||
builder.WriteString("[by:Implemented by SpotiFLAC-Mobile using Paxsenix API]\n")
|
||||
builder.WriteString("\n")
|
||||
|
||||
if lyrics.SyncType == "LINE_SYNCED" {
|
||||
|
||||
@@ -2662,6 +2662,15 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
req.DiscNumber,
|
||||
)
|
||||
|
||||
// Prefer the cover URL the frontend sent (user-selected album) over the
|
||||
// track's default album cover returned by the Qobuz track/get API, which
|
||||
// may belong to a different album when the same track appears on multiple
|
||||
// releases.
|
||||
resultCoverURL := strings.TrimSpace(req.CoverURL)
|
||||
if resultCoverURL == "" {
|
||||
resultCoverURL = strings.TrimSpace(qobuzTrackAlbumImage(track))
|
||||
}
|
||||
|
||||
return QobuzDownloadResult{
|
||||
FilePath: outputPath,
|
||||
BitDepth: actualBitDepth,
|
||||
@@ -2673,7 +2682,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
TrackNumber: resultTrackNumber,
|
||||
DiscNumber: resultDiscNumber,
|
||||
ISRC: track.ISRC,
|
||||
CoverURL: strings.TrimSpace(qobuzTrackAlbumImage(track)),
|
||||
CoverURL: resultCoverURL,
|
||||
LyricsLRC: lyricsLRC,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -519,7 +519,7 @@ class _LogEntryTile extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
entry.message,
|
||||
entry.previewMessage,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontFamily: 'monospace',
|
||||
@@ -527,10 +527,10 @@ class _LogEntryTile extends StatelessWidget {
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
if (entry.error != null) ...[
|
||||
if (entry.previewError != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
entry.error!,
|
||||
entry.previewError!,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
|
||||
@@ -1158,8 +1158,11 @@ class FFmpegService {
|
||||
// For M4A/MP4, cover art is mapped as a video stream and stored in the
|
||||
// 'covr' atom automatically by FFmpeg. The '-disposition attached_pic'
|
||||
// flag is only valid for Matroska/WebM containers and must NOT be used here.
|
||||
// Force the mp4 muxer when cover art is present because the default ipod
|
||||
// muxer (auto-selected for .m4a) does not register a codec tag for mjpeg,
|
||||
// causing "codec not currently supported in container" on FFmpeg 8.0+.
|
||||
if (hasCover) {
|
||||
cmdBuffer.write('-map 1:v -c:v copy ');
|
||||
cmdBuffer.write('-map 1:v -c:v copy -f mp4 ');
|
||||
}
|
||||
|
||||
cmdBuffer.write('-c:a copy ');
|
||||
|
||||
@@ -89,6 +89,10 @@ class LogEntry {
|
||||
return '$h:$m:$s.$ms';
|
||||
}
|
||||
|
||||
String get previewMessage => _truncateLogText(message);
|
||||
|
||||
String? get previewError => error == null ? null : _truncateLogText(error!);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final errorPart = error != null ? ' | $error' : '';
|
||||
@@ -128,11 +132,9 @@ class LogBuffer extends ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
|
||||
final sanitizedMessage = _truncateLogText(
|
||||
_redactSensitiveText(entry.message),
|
||||
);
|
||||
final sanitizedMessage = _redactSensitiveText(entry.message);
|
||||
final sanitizedError = entry.error != null
|
||||
? _truncateLogText(_redactSensitiveText(entry.error!))
|
||||
? _redactSensitiveText(entry.error!)
|
||||
: null;
|
||||
final sanitizedEntry =
|
||||
(sanitizedMessage == entry.message && sanitizedError == entry.error)
|
||||
@@ -381,9 +383,7 @@ class BufferedOutput extends LogOutput {
|
||||
}
|
||||
|
||||
final level = _levelToString(event.level);
|
||||
final message = _truncateLogText(
|
||||
_redactSensitiveText(event.lines.join('\n')),
|
||||
);
|
||||
final message = _redactSensitiveText(event.lines.join('\n'));
|
||||
|
||||
LogBuffer().add(
|
||||
LogEntry(
|
||||
|
||||
Reference in New Issue
Block a user