mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-06-04 05:38:12 +02:00
fix: harden download errors and re-enrich sidecars
This commit is contained in:
@@ -1037,6 +1037,48 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a ".lrc" sidecar next to a SAF audio document. The sidecar reuses
|
||||
* the audio file's base name (e.g. "Song.flac" -> "Song.lrc") and is created
|
||||
* in the same parent directory. Used by re-enrich when the user's lyrics
|
||||
* mode requests an external/both sidecar. Best-effort: failures are logged
|
||||
* and swallowed so they never abort the metadata enrichment itself.
|
||||
*/
|
||||
private fun writeSafSidecarLrc(audioUri: Uri, lrcContent: String): Boolean {
|
||||
if (lrcContent.isBlank()) return false
|
||||
try {
|
||||
val parent = safParentDir(audioUri) ?: run {
|
||||
android.util.Log.w("SpotiFLAC", "LRC sidecar: no SAF parent dir")
|
||||
return false
|
||||
}
|
||||
val audioName = try {
|
||||
DocumentFile.fromSingleUri(this, audioUri)?.name
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} ?: return false
|
||||
val baseName = audioName.substringBeforeLast('.', audioName)
|
||||
val lrcName = "$baseName.lrc"
|
||||
|
||||
val target = createOrReuseDocumentFile(
|
||||
parent,
|
||||
"application/octet-stream",
|
||||
lrcName
|
||||
) ?: run {
|
||||
android.util.Log.w("SpotiFLAC", "LRC sidecar: failed to create $lrcName")
|
||||
return false
|
||||
}
|
||||
|
||||
contentResolver.openOutputStream(target.uri, "wt")?.use { output ->
|
||||
output.write(lrcContent.toByteArray(Charsets.UTF_8))
|
||||
} ?: return false
|
||||
android.util.Log.d("SpotiFLAC", "LRC sidecar written: $lrcName")
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("SpotiFLAC", "LRC sidecar write failed: ${e.message}")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the audio filename referenced by a CUE sheet file.
|
||||
* Reads the FILE "name" TYPE line from the .cue text.
|
||||
@@ -2604,6 +2646,23 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"writeSafSidecarLrc" -> {
|
||||
val safUri = call.argument<String>("saf_uri") ?: ""
|
||||
val lyrics = call.argument<String>("lyrics") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val uri = Uri.parse(safUri)
|
||||
if (writeSafSidecarLrc(uri, lyrics)) {
|
||||
"""{"success":true}"""
|
||||
} else {
|
||||
"""{"success":false,"error":"Failed to write LRC sidecar"}"""
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
"""{"success":false,"error":"${e.message?.replace("\"", "'")}"}"""
|
||||
}
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"downloadCoverToFile" -> {
|
||||
val coverUrl = call.argument<String>("cover_url") ?: ""
|
||||
val outputPath = call.argument<String>("output_path") ?: ""
|
||||
@@ -2761,6 +2820,9 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
if (!writeUriFromPath(uri, tempPath)) {
|
||||
return@withContext """{"error":"Failed to write enriched metadata back to SAF file"}"""
|
||||
}
|
||||
if (obj.optBoolean("write_external_lrc", false)) {
|
||||
writeSafSidecarLrc(uri, obj.optString("lyrics", ""))
|
||||
}
|
||||
raw
|
||||
} catch (e: Exception) {
|
||||
try { File(tempPath).delete() } catch (_: Exception) {}
|
||||
|
||||
+65
-33
@@ -379,6 +379,7 @@ type reEnrichRequest struct {
|
||||
CoverURL string `json:"cover_url"`
|
||||
MaxQuality bool `json:"max_quality"`
|
||||
EmbedLyrics bool `json:"embed_lyrics"`
|
||||
LyricsMode string `json:"lyrics_mode,omitempty"`
|
||||
ArtistTagMode string `json:"artist_tag_mode,omitempty"`
|
||||
SpotifyID string `json:"spotify_id"`
|
||||
TrackName string `json:"track_name"`
|
||||
@@ -414,6 +415,21 @@ func (r *reEnrichRequest) shouldUpdateField(field string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// lyricsEmbedEnabled reports whether lyrics should be written into the audio
|
||||
// file's tags. It mirrors the download path semantics: 'embed' and 'both' embed,
|
||||
// 'external' does not. An empty mode keeps the legacy behavior (embed) so older
|
||||
// callers that do not send lyrics_mode are unaffected.
|
||||
func (r *reEnrichRequest) lyricsEmbedEnabled() bool {
|
||||
return strings.ToLower(strings.TrimSpace(r.LyricsMode)) != "external"
|
||||
}
|
||||
|
||||
// lyricsSidecarEnabled reports whether a .lrc sidecar file should be written
|
||||
// next to the audio file. Only 'external' and 'both' request a sidecar.
|
||||
func (r *reEnrichRequest) lyricsSidecarEnabled() bool {
|
||||
mode := strings.ToLower(strings.TrimSpace(r.LyricsMode))
|
||||
return mode == "external" || mode == "both"
|
||||
}
|
||||
|
||||
func applyReEnrichTrackMetadata(req *reEnrichRequest, track ExtTrackMetadata) {
|
||||
if req == nil {
|
||||
return
|
||||
@@ -578,7 +594,7 @@ func buildReEnrichFFmpegMetadata(req *reEnrichRequest, lyricsLRC string) map[str
|
||||
}
|
||||
}
|
||||
if req.shouldUpdateField("lyrics") {
|
||||
if lyricsLRC != "" {
|
||||
if lyricsLRC != "" && req.lyricsEmbedEnabled() {
|
||||
metadata["LYRICS"] = lyricsLRC
|
||||
metadata["UNSYNCEDLYRICS"] = lyricsLRC
|
||||
}
|
||||
@@ -2358,37 +2374,7 @@ func GetTidalURLFromDeezerTrack(deezerTrackID string) (string, error) {
|
||||
}
|
||||
|
||||
func errorResponse(msg string) (string, error) {
|
||||
errorType := "unknown"
|
||||
lowerMsg := strings.ToLower(msg)
|
||||
|
||||
if strings.Contains(lowerMsg, "isp blocking") ||
|
||||
strings.Contains(lowerMsg, "try using vpn") ||
|
||||
strings.Contains(lowerMsg, "change dns") {
|
||||
errorType = "isp_blocked"
|
||||
} else if strings.Contains(lowerMsg, "cancel") {
|
||||
errorType = "cancelled"
|
||||
} else if strings.Contains(lowerMsg, "permission") ||
|
||||
strings.Contains(lowerMsg, "operation not permitted") ||
|
||||
strings.Contains(lowerMsg, "access denied") ||
|
||||
strings.Contains(lowerMsg, "failed to create file") ||
|
||||
strings.Contains(lowerMsg, "failed to create directory") {
|
||||
errorType = "permission"
|
||||
} else if strings.Contains(lowerMsg, "not found") ||
|
||||
strings.Contains(lowerMsg, "not available") ||
|
||||
strings.Contains(lowerMsg, "no results") ||
|
||||
strings.Contains(lowerMsg, "track not found") ||
|
||||
strings.Contains(lowerMsg, "all services failed") {
|
||||
errorType = "not_found"
|
||||
} else if strings.Contains(lowerMsg, "rate limit") ||
|
||||
strings.Contains(lowerMsg, "429") ||
|
||||
strings.Contains(lowerMsg, "too many requests") {
|
||||
errorType = "rate_limit"
|
||||
} else if strings.Contains(lowerMsg, "network") ||
|
||||
strings.Contains(lowerMsg, "connection") ||
|
||||
strings.Contains(lowerMsg, "timeout") ||
|
||||
strings.Contains(lowerMsg, "dial") {
|
||||
errorType = "network"
|
||||
}
|
||||
errorType := classifyDownloadErrorType(msg)
|
||||
|
||||
resp := DownloadResponse{
|
||||
Success: false,
|
||||
@@ -2399,6 +2385,41 @@ func errorResponse(msg string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func classifyDownloadErrorType(msg string) string {
|
||||
lowerMsg := strings.ToLower(msg)
|
||||
|
||||
if strings.Contains(lowerMsg, "isp blocking") ||
|
||||
strings.Contains(lowerMsg, "try using vpn") ||
|
||||
strings.Contains(lowerMsg, "change dns") {
|
||||
return "isp_blocked"
|
||||
} else if strings.Contains(lowerMsg, "cancel") {
|
||||
return "cancelled"
|
||||
} else if strings.Contains(lowerMsg, "rate limit") ||
|
||||
strings.Contains(lowerMsg, "429") ||
|
||||
strings.Contains(lowerMsg, "too many requests") {
|
||||
return "rate_limit"
|
||||
} else if strings.Contains(lowerMsg, "permission") ||
|
||||
strings.Contains(lowerMsg, "operation not permitted") ||
|
||||
strings.Contains(lowerMsg, "access denied") ||
|
||||
strings.Contains(lowerMsg, "failed to create file") ||
|
||||
strings.Contains(lowerMsg, "failed to create directory") {
|
||||
return "permission"
|
||||
} else if strings.Contains(lowerMsg, "not found") ||
|
||||
strings.Contains(lowerMsg, "not available") ||
|
||||
strings.Contains(lowerMsg, "no results") ||
|
||||
strings.Contains(lowerMsg, "track not found") ||
|
||||
strings.Contains(lowerMsg, "all services failed") {
|
||||
return "not_found"
|
||||
} else if strings.Contains(lowerMsg, "network") ||
|
||||
strings.Contains(lowerMsg, "connection") ||
|
||||
strings.Contains(lowerMsg, "timeout") ||
|
||||
strings.Contains(lowerMsg, "dial") {
|
||||
return "network"
|
||||
}
|
||||
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func DownloadCoverToFile(coverURL string, outputPath string, maxQuality bool) error {
|
||||
if coverURL == "" {
|
||||
return fmt.Errorf("no cover URL provided")
|
||||
@@ -2745,7 +2766,9 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
||||
metadata.ISRC = req.ISRC
|
||||
}
|
||||
if req.shouldUpdateField("lyrics") {
|
||||
metadata.Lyrics = lyricsLRC
|
||||
if req.lyricsEmbedEnabled() {
|
||||
metadata.Lyrics = lyricsLRC
|
||||
}
|
||||
}
|
||||
if req.shouldUpdateField("extra") {
|
||||
metadata.Genre = req.Genre
|
||||
@@ -2780,6 +2803,11 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
||||
"method": "native",
|
||||
"success": true,
|
||||
"enriched_metadata": enrichedMeta,
|
||||
"lyrics": lyricsLRC,
|
||||
"write_external_lrc": req.EmbedLyrics &&
|
||||
req.shouldUpdateField("lyrics") &&
|
||||
req.lyricsSidecarEnabled() &&
|
||||
strings.TrimSpace(lyricsLRC) != "",
|
||||
}
|
||||
jsonBytes, _ := json.Marshal(result)
|
||||
return string(jsonBytes), nil
|
||||
@@ -2795,6 +2823,10 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
||||
"lyrics": lyricsLRC,
|
||||
"enriched_metadata": enrichedMeta,
|
||||
"metadata": ffmpegMetadata,
|
||||
"write_external_lrc": req.EmbedLyrics &&
|
||||
req.shouldUpdateField("lyrics") &&
|
||||
req.lyricsSidecarEnabled() &&
|
||||
strings.TrimSpace(lyricsLRC) != "",
|
||||
}
|
||||
|
||||
jsonBytes, _ := json.Marshal(result)
|
||||
|
||||
@@ -11,6 +11,26 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDownloadErrorClassificationPrioritizesRateLimit(t *testing.T) {
|
||||
got := classifyDownloadErrorType("All providers failed. Last error: HTTP status 429: too many requests")
|
||||
if got != "rate_limit" {
|
||||
t.Fatalf("expected rate_limit, got %q", got)
|
||||
}
|
||||
|
||||
responseJSON, err := errorResponse("All services failed. Last error: rate limit exceeded")
|
||||
if err != nil {
|
||||
t.Fatalf("errorResponse returned error: %v", err)
|
||||
}
|
||||
|
||||
var response DownloadResponse
|
||||
if err := json.Unmarshal([]byte(responseJSON), &response); err != nil {
|
||||
t.Fatalf("invalid response JSON: %v", err)
|
||||
}
|
||||
if response.ErrorType != "rate_limit" {
|
||||
t.Fatalf("expected rate_limit response, got %q", response.ErrorType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dataDir := filepath.Join(dir, "data")
|
||||
|
||||
@@ -391,10 +391,14 @@ func resolveExtensionAvailabilityReason(availability *ExtAvailabilityResult, err
|
||||
|
||||
func buildExtensionFallbackStoppedResponse(providerID string, availability *ExtAvailabilityResult, err error) *DownloadResponse {
|
||||
reason := resolveExtensionAvailabilityReason(availability, err)
|
||||
errorType := classifyDownloadErrorType(reason)
|
||||
if errorType == "unknown" {
|
||||
errorType = "extension_error"
|
||||
}
|
||||
return &DownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("Fallback stopped by %s: %s", providerID, reason),
|
||||
ErrorType: "extension_error",
|
||||
ErrorType: errorType,
|
||||
Service: providerID,
|
||||
}
|
||||
}
|
||||
@@ -2519,10 +2523,14 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
}
|
||||
|
||||
if lastErr != nil {
|
||||
errorType := classifyDownloadErrorType(lastErr.Error())
|
||||
if errorType == "unknown" {
|
||||
errorType = "not_found"
|
||||
}
|
||||
return &DownloadResponse{
|
||||
Success: false,
|
||||
Error: "All providers failed. Last error: " + lastErr.Error(),
|
||||
ErrorType: "not_found",
|
||||
ErrorType: errorType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -2557,9 +2565,10 @@ func buildOutputPath(req DownloadRequest) string {
|
||||
}
|
||||
|
||||
filename := buildFilenameFromTemplate(req.FilenameFormat, metadata)
|
||||
if filename == "" {
|
||||
filename = sanitizeFilename(fmt.Sprintf("%s - %s", req.ArtistName, req.TrackName))
|
||||
if strings.TrimSpace(filename) == "" {
|
||||
filename = fmt.Sprintf("%s - %s", req.ArtistName, req.TrackName)
|
||||
}
|
||||
filename = sanitizeFilename(filename)
|
||||
|
||||
ext := strings.TrimSpace(req.OutputExt)
|
||||
if ext == "" {
|
||||
@@ -2615,9 +2624,10 @@ func buildOutputPathForExtension(req DownloadRequest, ext *loadedExtension) stri
|
||||
}
|
||||
|
||||
filename := buildFilenameFromTemplate(req.FilenameFormat, metadata)
|
||||
if filename == "" {
|
||||
filename = sanitizeFilename(fmt.Sprintf("%s - %s", req.ArtistName, req.TrackName))
|
||||
if strings.TrimSpace(filename) == "" {
|
||||
filename = fmt.Sprintf("%s - %s", req.ArtistName, req.TrackName)
|
||||
}
|
||||
filename = sanitizeFilename(filename)
|
||||
|
||||
outputExt := strings.TrimSpace(req.OutputExt)
|
||||
if outputExt == "" {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -286,6 +287,45 @@ func TestBuildOutputPathForExtensionUsesTempDirForFDOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOutputPathSanitizesTemplateFilename(t *testing.T) {
|
||||
SetAllowedDownloadDirs(nil)
|
||||
|
||||
outputDir := t.TempDir()
|
||||
outputPath := buildOutputPath(DownloadRequest{
|
||||
TrackName: `Gehra Hua (From "Dhurandhar")`,
|
||||
ArtistName: "Artist",
|
||||
OutputDir: outputDir,
|
||||
OutputExt: ".flac",
|
||||
FilenameFormat: "{artist} - {title}",
|
||||
})
|
||||
|
||||
base := filepath.Base(outputPath)
|
||||
if strings.ContainsAny(base, `<>:"/\|?*`) {
|
||||
t.Fatalf("output filename still contains illegal characters: %q", base)
|
||||
}
|
||||
if strings.Contains(base, `"`) {
|
||||
t.Fatalf("output filename still contains straight double quote: %q", base)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOutputPathForExtensionSanitizesTemplateFilename(t *testing.T) {
|
||||
SetAllowedDownloadDirs(nil)
|
||||
|
||||
ext := &loadedExtension{DataDir: t.TempDir()}
|
||||
resolved := buildOutputPathForExtension(DownloadRequest{
|
||||
TrackName: `Gehra Hua (From "Dhurandhar")`,
|
||||
ArtistName: "Artist",
|
||||
OutputFD: 123,
|
||||
OutputExt: ".flac",
|
||||
FilenameFormat: "{artist} - {title}",
|
||||
}, ext)
|
||||
|
||||
base := filepath.Base(resolved)
|
||||
if strings.ContainsAny(base, `<>:"/\|?*`) {
|
||||
t.Fatalf("extension output filename still contains illegal characters: %q", base)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldStopProviderFallback(t *testing.T) {
|
||||
if shouldStopProviderFallback(nil) {
|
||||
t.Fatal("nil availability should not stop fallback")
|
||||
|
||||
+4
-11
@@ -77,6 +77,7 @@ var sharedTransport = &http.Transport{
|
||||
WriteBufferSize: 64 * 1024,
|
||||
ReadBufferSize: 64 * 1024,
|
||||
DisableCompression: true,
|
||||
TLSClientConfig: newTLSCompatibilityConfig(false),
|
||||
}
|
||||
|
||||
var extensionAPITransport = &http.Transport{
|
||||
@@ -95,6 +96,7 @@ var extensionAPITransport = &http.Transport{
|
||||
WriteBufferSize: 64 * 1024,
|
||||
ReadBufferSize: 64 * 1024,
|
||||
DisableCompression: false,
|
||||
TLSClientConfig: newTLSCompatibilityConfig(false),
|
||||
}
|
||||
|
||||
var metadataTransport = &http.Transport{
|
||||
@@ -113,6 +115,7 @@ var metadataTransport = &http.Transport{
|
||||
WriteBufferSize: 32 * 1024,
|
||||
ReadBufferSize: 32 * 1024,
|
||||
DisableCompression: true,
|
||||
TLSClientConfig: newTLSCompatibilityConfig(false),
|
||||
}
|
||||
|
||||
var sharedClient = &http.Client{
|
||||
@@ -176,17 +179,7 @@ func GetNetworkCompatibilityOptions() NetworkCompatibilityOptions {
|
||||
}
|
||||
|
||||
func applyTLSCompatibility(transport *http.Transport, insecureTLS bool) {
|
||||
if insecureTLS {
|
||||
cfg := &tls.Config{InsecureSkipVerify: true}
|
||||
if transport.TLSClientConfig != nil {
|
||||
cfg = transport.TLSClientConfig.Clone()
|
||||
cfg.InsecureSkipVerify = true
|
||||
}
|
||||
transport.TLSClientConfig = cfg
|
||||
return
|
||||
}
|
||||
|
||||
transport.TLSClientConfig = nil
|
||||
transport.TLSClientConfig = newTLSCompatibilityConfig(insecureTLS)
|
||||
}
|
||||
|
||||
type compatibilityTransport struct {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -25,11 +27,34 @@ func TestHTTPUtilityHelpers(t *testing.T) {
|
||||
if GetSharedClient() == nil || GetDownloadClient() == nil {
|
||||
t.Fatal("expected shared clients")
|
||||
}
|
||||
if sharedTransport.TLSClientConfig == nil || sharedTransport.TLSClientConfig.RootCAs == nil {
|
||||
t.Fatal("expected supplemental TLS root pool")
|
||||
}
|
||||
block, _ := pem.Decode([]byte(isrgRootX2PEM))
|
||||
if block == nil {
|
||||
t.Fatal("failed to decode ISRG Root X2")
|
||||
}
|
||||
rootX2, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse ISRG Root X2: %v", err)
|
||||
}
|
||||
if _, err := rootX2.Verify(x509.VerifyOptions{
|
||||
Roots: supplementalRootCAs(),
|
||||
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
|
||||
}); err != nil {
|
||||
t.Fatalf("ISRG Root X2 should verify with supplemental roots: %v", err)
|
||||
}
|
||||
SetNetworkCompatibilityOptions(true, true)
|
||||
if opts := GetNetworkCompatibilityOptions(); !opts.AllowHTTP || !opts.InsecureTLS {
|
||||
t.Fatalf("network opts = %#v", opts)
|
||||
}
|
||||
if !sharedTransport.TLSClientConfig.InsecureSkipVerify {
|
||||
t.Fatal("expected insecure TLS config to be applied")
|
||||
}
|
||||
SetNetworkCompatibilityOptions(false, false)
|
||||
if sharedTransport.TLSClientConfig == nil || sharedTransport.TLSClientConfig.InsecureSkipVerify {
|
||||
t.Fatal("expected secure TLS config to be restored")
|
||||
}
|
||||
if !canFallbackToHTTP(&http.Request{Method: http.MethodGet}) {
|
||||
t.Fatal("GET should fallback")
|
||||
}
|
||||
|
||||
@@ -42,9 +42,12 @@ func (t *utlsTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
opts := GetNetworkCompatibilityOptions()
|
||||
tlsConn := utls.UClient(conn, &utls.Config{
|
||||
ServerName: host,
|
||||
NextProtos: []string{"h2", "http/1.1"},
|
||||
RootCAs: supplementalRootCAs(),
|
||||
InsecureSkipVerify: opts.InsecureTLS,
|
||||
ServerName: host,
|
||||
NextProtos: []string{"h2", "http/1.1"},
|
||||
}, utls.HelloChrome_Auto)
|
||||
|
||||
if err := tlsConn.Handshake(); err != nil {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const isrgRootX1PEM = `-----BEGIN CERTIFICATE-----
|
||||
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
|
||||
-----END CERTIFICATE-----`
|
||||
|
||||
const isrgRootX2PEM = `-----BEGIN CERTIFICATE-----
|
||||
MIICGzCCAaGgAwIBAgIQQdKd0XLq7qeAwSxs6S+HUjAKBggqhkjOPQQDAzBPMQsw
|
||||
CQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2gg
|
||||
R3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMjAeFw0yMDA5MDQwMDAwMDBaFw00
|
||||
MDA5MTcxNjAwMDBaME8xCzAJBgNVBAYTAlVTMSkwJwYDVQQKEyBJbnRlcm5ldCBT
|
||||
ZWN1cml0eSBSZXNlYXJjaCBHcm91cDEVMBMGA1UEAxMMSVNSRyBSb290IFgyMHYw
|
||||
EAYHKoZIzj0CAQYFK4EEACIDYgAEzZvVn4CDCuwJSvMWSj5cz3es3mcFDR0HttwW
|
||||
+1qLFNvicWDEukWVEYmO6gbf9yoWHKS5xcUy4APgHoIYOIvXRdgKam7mAHf7AlF9
|
||||
ItgKbppbd9/w+kHsOdx1ymgHDB/qo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0T
|
||||
AQH/BAUwAwEB/zAdBgNVHQ4EFgQUfEKWrt5LSDv6kviejM9ti6lyN5UwCgYIKoZI
|
||||
zj0EAwMDaAAwZQIwe3lORlCEwkSHRhtFcP9Ymd70/aTSVaYgLXTWNLxBo1BfASdW
|
||||
tL4ndQavEi51mI38AjEAi/V3bNTIZargCyzuFJ0nN6T5U6VR5CmD1/iQMVtCnwr1
|
||||
/q4AaOeMSQ+2b1tbFfLn
|
||||
-----END CERTIFICATE-----`
|
||||
|
||||
var (
|
||||
supplementalRootCAsOnce sync.Once
|
||||
supplementalRootCAsPool *x509.CertPool
|
||||
)
|
||||
|
||||
func supplementalRootCAs() *x509.CertPool {
|
||||
supplementalRootCAsOnce.Do(func() {
|
||||
pool, err := x509.SystemCertPool()
|
||||
if err != nil || pool == nil {
|
||||
pool = x509.NewCertPool()
|
||||
}
|
||||
|
||||
for _, pem := range []string{isrgRootX1PEM, isrgRootX2PEM} {
|
||||
pool.AppendCertsFromPEM([]byte(pem))
|
||||
}
|
||||
supplementalRootCAsPool = pool
|
||||
})
|
||||
|
||||
return supplementalRootCAsPool
|
||||
}
|
||||
|
||||
func newTLSCompatibilityConfig(insecureTLS bool) *tls.Config {
|
||||
return &tls.Config{
|
||||
RootCAs: supplementalRootCAs(),
|
||||
InsecureSkipVerify: insecureTLS,
|
||||
}
|
||||
}
|
||||
@@ -6209,6 +6209,18 @@ abstract class AppLocalizations {
|
||||
/// **'Download completed'**
|
||||
String get queueDownloadCompleted;
|
||||
|
||||
/// Title shown on a failed queue item when the download service rate limits requests
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Service rate limited'**
|
||||
String get queueRateLimitTitle;
|
||||
|
||||
/// Explanation shown on a failed queue item when the download service rate limits requests
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.'**
|
||||
String get queueRateLimitMessage;
|
||||
|
||||
/// Accessibility label for picking an accent color
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
||||
@@ -3696,6 +3696,13 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get queueDownloadCompleted => 'Download completed';
|
||||
|
||||
@override
|
||||
String get queueRateLimitTitle => 'Service rate limited';
|
||||
|
||||
@override
|
||||
String get queueRateLimitMessage =>
|
||||
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
|
||||
|
||||
@override
|
||||
String appearanceSelectAccentColor(String hex) {
|
||||
return 'Select accent color $hex';
|
||||
|
||||
@@ -3667,6 +3667,13 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get queueDownloadCompleted => 'Download completed';
|
||||
|
||||
@override
|
||||
String get queueRateLimitTitle => 'Service rate limited';
|
||||
|
||||
@override
|
||||
String get queueRateLimitMessage =>
|
||||
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
|
||||
|
||||
@override
|
||||
String appearanceSelectAccentColor(String hex) {
|
||||
return 'Select accent color $hex';
|
||||
|
||||
@@ -3661,6 +3661,13 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get queueDownloadCompleted => 'Download completed';
|
||||
|
||||
@override
|
||||
String get queueRateLimitTitle => 'Service rate limited';
|
||||
|
||||
@override
|
||||
String get queueRateLimitMessage =>
|
||||
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
|
||||
|
||||
@override
|
||||
String appearanceSelectAccentColor(String hex) {
|
||||
return 'Select accent color $hex';
|
||||
|
||||
@@ -3665,6 +3665,13 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get queueDownloadCompleted => 'Download completed';
|
||||
|
||||
@override
|
||||
String get queueRateLimitTitle => 'Service rate limited';
|
||||
|
||||
@override
|
||||
String get queueRateLimitMessage =>
|
||||
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
|
||||
|
||||
@override
|
||||
String appearanceSelectAccentColor(String hex) {
|
||||
return 'Select accent color $hex';
|
||||
|
||||
@@ -3662,6 +3662,13 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get queueDownloadCompleted => 'Download completed';
|
||||
|
||||
@override
|
||||
String get queueRateLimitTitle => 'Service rate limited';
|
||||
|
||||
@override
|
||||
String get queueRateLimitMessage =>
|
||||
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
|
||||
|
||||
@override
|
||||
String appearanceSelectAccentColor(String hex) {
|
||||
return 'Select accent color $hex';
|
||||
|
||||
@@ -3653,6 +3653,13 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get queueDownloadCompleted => 'Download completed';
|
||||
|
||||
@override
|
||||
String get queueRateLimitTitle => 'Layanan sedang membatasi permintaan';
|
||||
|
||||
@override
|
||||
String get queueRateLimitMessage =>
|
||||
'Lagu ini mungkin masih tersedia. Tunggu beberapa menit, kurangi unduhan paralel, lalu coba lagi.';
|
||||
|
||||
@override
|
||||
String appearanceSelectAccentColor(String hex) {
|
||||
return 'Select accent color $hex';
|
||||
|
||||
@@ -3649,6 +3649,13 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get queueDownloadCompleted => 'Download completed';
|
||||
|
||||
@override
|
||||
String get queueRateLimitTitle => 'Service rate limited';
|
||||
|
||||
@override
|
||||
String get queueRateLimitMessage =>
|
||||
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
|
||||
|
||||
@override
|
||||
String appearanceSelectAccentColor(String hex) {
|
||||
return 'Select accent color $hex';
|
||||
|
||||
@@ -3642,6 +3642,13 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get queueDownloadCompleted => 'Download completed';
|
||||
|
||||
@override
|
||||
String get queueRateLimitTitle => 'Service rate limited';
|
||||
|
||||
@override
|
||||
String get queueRateLimitMessage =>
|
||||
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
|
||||
|
||||
@override
|
||||
String appearanceSelectAccentColor(String hex) {
|
||||
return 'Select accent color $hex';
|
||||
|
||||
@@ -3662,6 +3662,13 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get queueDownloadCompleted => 'Download completed';
|
||||
|
||||
@override
|
||||
String get queueRateLimitTitle => 'Service rate limited';
|
||||
|
||||
@override
|
||||
String get queueRateLimitMessage =>
|
||||
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
|
||||
|
||||
@override
|
||||
String appearanceSelectAccentColor(String hex) {
|
||||
return 'Select accent color $hex';
|
||||
|
||||
@@ -3661,6 +3661,13 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get queueDownloadCompleted => 'Download completed';
|
||||
|
||||
@override
|
||||
String get queueRateLimitTitle => 'Service rate limited';
|
||||
|
||||
@override
|
||||
String get queueRateLimitMessage =>
|
||||
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
|
||||
|
||||
@override
|
||||
String appearanceSelectAccentColor(String hex) {
|
||||
return 'Select accent color $hex';
|
||||
|
||||
@@ -3721,6 +3721,13 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get queueDownloadCompleted => 'Download completed';
|
||||
|
||||
@override
|
||||
String get queueRateLimitTitle => 'Service rate limited';
|
||||
|
||||
@override
|
||||
String get queueRateLimitMessage =>
|
||||
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
|
||||
|
||||
@override
|
||||
String appearanceSelectAccentColor(String hex) {
|
||||
return 'Select accent color $hex';
|
||||
|
||||
@@ -3688,6 +3688,13 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
@override
|
||||
String get queueDownloadCompleted => 'Download completed';
|
||||
|
||||
@override
|
||||
String get queueRateLimitTitle => 'Service rate limited';
|
||||
|
||||
@override
|
||||
String get queueRateLimitMessage =>
|
||||
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
|
||||
|
||||
@override
|
||||
String appearanceSelectAccentColor(String hex) {
|
||||
return 'Select accent color $hex';
|
||||
|
||||
@@ -3721,6 +3721,13 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
@override
|
||||
String get queueDownloadCompleted => 'Download completed';
|
||||
|
||||
@override
|
||||
String get queueRateLimitTitle => 'Service rate limited';
|
||||
|
||||
@override
|
||||
String get queueRateLimitMessage =>
|
||||
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
|
||||
|
||||
@override
|
||||
String appearanceSelectAccentColor(String hex) {
|
||||
return 'Select accent color $hex';
|
||||
|
||||
@@ -3661,6 +3661,13 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get queueDownloadCompleted => 'Download completed';
|
||||
|
||||
@override
|
||||
String get queueRateLimitTitle => 'Service rate limited';
|
||||
|
||||
@override
|
||||
String get queueRateLimitMessage =>
|
||||
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
|
||||
|
||||
@override
|
||||
String appearanceSelectAccentColor(String hex) {
|
||||
return 'Select accent color $hex';
|
||||
|
||||
@@ -4808,6 +4808,14 @@
|
||||
"@queueDownloadCompleted": {
|
||||
"description": "Accessibility label for completed download state in queue"
|
||||
},
|
||||
"queueRateLimitTitle": "Service rate limited",
|
||||
"@queueRateLimitTitle": {
|
||||
"description": "Title shown on a failed queue item when the download service rate limits requests"
|
||||
},
|
||||
"queueRateLimitMessage": "This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.",
|
||||
"@queueRateLimitMessage": {
|
||||
"description": "Explanation shown on a failed queue item when the download service rate limits requests"
|
||||
},
|
||||
"appearanceSelectAccentColor": "Select accent color {hex}",
|
||||
"@appearanceSelectAccentColor": {
|
||||
"description": "Accessibility label for picking an accent color",
|
||||
|
||||
@@ -4614,5 +4614,13 @@
|
||||
"downloadFallbackExtensionsSubtitle": "Choose which extensions can be used as fallback",
|
||||
"@downloadFallbackExtensionsSubtitle": {
|
||||
"description": "Subtitle for fallback extensions item"
|
||||
},
|
||||
"queueRateLimitTitle": "Layanan sedang membatasi permintaan",
|
||||
"@queueRateLimitTitle": {
|
||||
"description": "Title shown on a failed queue item when the download service rate limits requests"
|
||||
},
|
||||
"queueRateLimitMessage": "Lagu ini mungkin masih tersedia. Tunggu beberapa menit, kurangi unduhan paralel, lalu coba lagi.",
|
||||
"@queueRateLimitMessage": {
|
||||
"description": "Explanation shown on a failed queue item when the download service rate limits requests"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ class DownloadItem {
|
||||
case DownloadErrorType.notFound:
|
||||
return 'Song not found on any service';
|
||||
case DownloadErrorType.rateLimit:
|
||||
return 'Rate limit reached, try again later';
|
||||
return 'Service rate limit reached. Wait before retrying.';
|
||||
case DownloadErrorType.network:
|
||||
return 'Connection failed, check your internet';
|
||||
case DownloadErrorType.permission:
|
||||
|
||||
@@ -6551,6 +6551,32 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
}
|
||||
|
||||
DownloadErrorType _downloadErrorTypeFromMessage(String errorMsg) {
|
||||
final lowerMsg = errorMsg.toLowerCase();
|
||||
if (errorMsg.contains('429') ||
|
||||
lowerMsg.contains('rate limit') ||
|
||||
lowerMsg.contains('too many requests')) {
|
||||
return DownloadErrorType.rateLimit;
|
||||
}
|
||||
if (lowerMsg.contains('not found') ||
|
||||
lowerMsg.contains('not available') ||
|
||||
lowerMsg.contains('no results')) {
|
||||
return DownloadErrorType.notFound;
|
||||
}
|
||||
if (lowerMsg.contains('permission') ||
|
||||
lowerMsg.contains('operation not permitted') ||
|
||||
lowerMsg.contains('access denied')) {
|
||||
return DownloadErrorType.permission;
|
||||
}
|
||||
if (lowerMsg.contains('network') ||
|
||||
lowerMsg.contains('connection') ||
|
||||
lowerMsg.contains('timeout') ||
|
||||
lowerMsg.contains('dial')) {
|
||||
return DownloadErrorType.network;
|
||||
}
|
||||
return DownloadErrorType.unknown;
|
||||
}
|
||||
|
||||
Future<void> _processQueue() async {
|
||||
if (state.isProcessing) return;
|
||||
|
||||
@@ -8723,7 +8749,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
errorType = DownloadErrorType.permission;
|
||||
break;
|
||||
default:
|
||||
errorType = DownloadErrorType.unknown;
|
||||
errorType = _downloadErrorTypeFromMessage(errorMsg);
|
||||
}
|
||||
|
||||
_log.e('Download failed: $errorMsg (type: $errorTypeStr)');
|
||||
@@ -8777,6 +8803,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
errorMsg.contains('track not found on Deezer')) {
|
||||
errorMsg = 'Track not found on Deezer (Metadata Unavailable)';
|
||||
errorType = DownloadErrorType.notFound;
|
||||
} else {
|
||||
errorType = _downloadErrorTypeFromMessage(errorMsg);
|
||||
}
|
||||
|
||||
updateItemStatus(
|
||||
|
||||
@@ -863,6 +863,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
await _safeDeleteFile(tempPath!);
|
||||
return false;
|
||||
}
|
||||
await writeReEnrichSafSidecarLrc(safUri: safUri, reEnrichResult: result);
|
||||
}
|
||||
|
||||
if (_hasValue(downloadedCoverPath)) {
|
||||
@@ -875,6 +876,15 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
await _safeDeleteFile(tempPath!);
|
||||
}
|
||||
|
||||
if (ffmpegResult != null) {
|
||||
// Filesystem .lrc sidecar. SAF sidecar is written only after
|
||||
// writeTempToSaf succeeds.
|
||||
await writeReEnrichSidecarLrc(
|
||||
audioFilePath: item.filePath,
|
||||
reEnrichResult: result,
|
||||
);
|
||||
}
|
||||
|
||||
return ffmpegResult != null;
|
||||
}
|
||||
|
||||
@@ -890,6 +900,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
'cover_url': '',
|
||||
'max_quality': true,
|
||||
'embed_lyrics': settings.embedLyrics,
|
||||
'lyrics_mode': settings.lyricsMode,
|
||||
'artist_tag_mode': artistTagMode,
|
||||
'spotify_id': '',
|
||||
'track_name': item.trackName,
|
||||
@@ -912,6 +923,11 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
final result = await PlatformBridge.reEnrichFile(request);
|
||||
final method = result['method'] as String?;
|
||||
if (method == 'native') {
|
||||
// Filesystem .lrc sidecar (SAF sidecar handled natively in Kotlin).
|
||||
await writeReEnrichSidecarLrc(
|
||||
audioFilePath: item.filePath,
|
||||
reEnrichResult: result,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
if (method == 'ffmpeg') {
|
||||
|
||||
@@ -4388,6 +4388,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
await _safeDeleteTempFile(tempPath!);
|
||||
return false;
|
||||
}
|
||||
await writeReEnrichSafSidecarLrc(safUri: safUri, reEnrichResult: result);
|
||||
}
|
||||
|
||||
if (_hasTextValue(downloadedCoverPath)) {
|
||||
@@ -4400,6 +4401,15 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
await _safeDeleteTempFile(tempPath!);
|
||||
}
|
||||
|
||||
if (ffmpegResult != null) {
|
||||
// Filesystem .lrc sidecar. SAF sidecar is written only after
|
||||
// writeTempToSaf succeeds.
|
||||
await writeReEnrichSidecarLrc(
|
||||
audioFilePath: item.filePath,
|
||||
reEnrichResult: result,
|
||||
);
|
||||
}
|
||||
|
||||
return ffmpegResult != null;
|
||||
}
|
||||
|
||||
@@ -4415,6 +4425,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
'cover_url': '',
|
||||
'max_quality': true,
|
||||
'embed_lyrics': settings.embedLyrics,
|
||||
'lyrics_mode': settings.lyricsMode,
|
||||
'artist_tag_mode': artistTagMode,
|
||||
'spotify_id': '',
|
||||
'track_name': item.trackName,
|
||||
@@ -4437,6 +4448,11 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
final result = await PlatformBridge.reEnrichFile(request);
|
||||
final method = result['method'] as String?;
|
||||
if (method == 'native') {
|
||||
// Filesystem .lrc sidecar (SAF sidecar handled natively in Kotlin).
|
||||
await writeReEnrichSidecarLrc(
|
||||
audioFilePath: item.filePath,
|
||||
reEnrichResult: result,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
if (method == 'ffmpeg') {
|
||||
@@ -5664,12 +5680,10 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
],
|
||||
if (item.status == DownloadStatus.failed) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
item.errorMessage,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.labelSmall
|
||||
?.copyWith(color: colorScheme.error),
|
||||
_buildDownloadFailureMessage(
|
||||
context,
|
||||
item,
|
||||
colorScheme,
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -5686,6 +5700,70 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDownloadFailureMessage(
|
||||
BuildContext context,
|
||||
DownloadItem item,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
if (item.errorType != DownloadErrorType.rateLimit) {
|
||||
return Text(
|
||||
item.errorMessage,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.copyWith(color: colorScheme.error),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.tertiaryContainer.withValues(alpha: 0.45),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: colorScheme.tertiary.withValues(alpha: 0.35)),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.hourglass_top_rounded,
|
||||
size: 16,
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.queueRateLimitTitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
context.l10n.queueRateLimitMessage,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCoverArt(DownloadItem item, ColorScheme colorScheme) {
|
||||
final coverSize = _queueCoverSize();
|
||||
|
||||
|
||||
@@ -2933,6 +2933,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
'cover_url': _coverUrl ?? '',
|
||||
'max_quality': true,
|
||||
'embed_lyrics': settings.embedLyrics,
|
||||
'lyrics_mode': settings.lyricsMode,
|
||||
'artist_tag_mode': artistTagMode,
|
||||
'spotify_id': _spotifyId ?? '',
|
||||
'track_name': trackName,
|
||||
@@ -2979,7 +2980,13 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
}
|
||||
|
||||
if (method == 'native') {
|
||||
// FLAC - handled natively by Go (SAF write-back handled in Kotlin)
|
||||
// FLAC - handled natively by Go (SAF write-back handled in Kotlin).
|
||||
// External .lrc sidecar for filesystem files is written here; SAF
|
||||
// sidecars are created natively in the Kotlin reEnrichFile handler.
|
||||
await writeReEnrichSidecarLrc(
|
||||
audioFilePath: cleanFilePath,
|
||||
reEnrichResult: result,
|
||||
);
|
||||
await _refreshEmbeddedCoverPreview(force: true);
|
||||
_markMetadataChanged();
|
||||
await _syncDownloadHistoryMetadata();
|
||||
@@ -3073,6 +3080,10 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
}
|
||||
return;
|
||||
}
|
||||
await writeReEnrichSafSidecarLrc(
|
||||
safUri: safUri,
|
||||
reEnrichResult: result,
|
||||
);
|
||||
}
|
||||
|
||||
if (tempPath != null && tempPath.isNotEmpty) {
|
||||
@@ -3082,6 +3093,12 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
}
|
||||
|
||||
if (ffmpegResult != null) {
|
||||
// Filesystem .lrc sidecar. SAF sidecar is written only after
|
||||
// writeTempToSaf succeeds.
|
||||
await writeReEnrichSidecarLrc(
|
||||
audioFilePath: cleanFilePath,
|
||||
reEnrichResult: result,
|
||||
);
|
||||
await _refreshEmbeddedCoverPreview(force: true);
|
||||
_markMetadataChanged();
|
||||
await _syncDownloadHistoryMetadata();
|
||||
|
||||
@@ -817,6 +817,15 @@ class PlatformBridge {
|
||||
return map['success'] == true;
|
||||
}
|
||||
|
||||
static Future<bool> writeSafSidecarLrc(String safUri, String lyrics) async {
|
||||
final result = await _channel.invokeMethod('writeSafSidecarLrc', {
|
||||
'saf_uri': safUri,
|
||||
'lyrics': lyrics,
|
||||
});
|
||||
final map = _decodeRequiredMapResult(result, 'writeSafSidecarLrc');
|
||||
return map['success'] == true;
|
||||
}
|
||||
|
||||
static Future<void> startDownloadService({
|
||||
String trackName = '',
|
||||
String artistName = '',
|
||||
|
||||
@@ -22,6 +22,57 @@ String _sidecarLrcPath(String path) {
|
||||
return '$path.lrc';
|
||||
}
|
||||
|
||||
/// Writes a ".lrc" sidecar next to a re-enriched audio file when the Go backend
|
||||
/// result requests it (`write_external_lrc`), honoring the user's lyrics mode.
|
||||
///
|
||||
/// This handles the filesystem case only. SAF (`content://`) files are written
|
||||
/// centrally by the Kotlin `reEnrichFile` handler, which still holds the
|
||||
/// original document URI, so callers should skip those here (they are detected
|
||||
/// and ignored). Best-effort: returns true only when a sidecar was actually
|
||||
/// written, and never throws.
|
||||
Future<bool> writeReEnrichSidecarLrc({
|
||||
required String audioFilePath,
|
||||
required Map<String, dynamic> reEnrichResult,
|
||||
}) async {
|
||||
if (reEnrichResult['write_external_lrc'] != true) return false;
|
||||
|
||||
// SAF documents are handled natively in Kotlin; nothing to do from Dart.
|
||||
if (isContentUri(audioFilePath)) return false;
|
||||
|
||||
final lrc = (reEnrichResult['lyrics'] as String?)?.trim() ?? '';
|
||||
if (lrc.isEmpty) return false;
|
||||
|
||||
try {
|
||||
final lrcPath = _sidecarLrcPath(audioFilePath);
|
||||
await File(lrcPath).writeAsString(lrc);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes a SAF ".lrc" sidecar after a FFmpeg re-enrich write-back succeeds.
|
||||
///
|
||||
/// Native FLAC re-enrich handles SAF sidecars in Kotlin after the direct
|
||||
/// write-back. This helper is for the FFmpeg path, where Dart owns the final
|
||||
/// `writeTempToSaf` success/failure decision.
|
||||
Future<bool> writeReEnrichSafSidecarLrc({
|
||||
required String safUri,
|
||||
required Map<String, dynamic> reEnrichResult,
|
||||
}) async {
|
||||
if (reEnrichResult['write_external_lrc'] != true) return false;
|
||||
if (!isContentUri(safUri)) return false;
|
||||
|
||||
final lrc = (reEnrichResult['lyrics'] as String?)?.trim() ?? '';
|
||||
if (lrc.isEmpty) return false;
|
||||
|
||||
try {
|
||||
return await PlatformBridge.writeSafSidecarLrc(safUri, lrc);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> ensureLyricsMetadataForConversion({
|
||||
required Map<String, String> metadata,
|
||||
required String sourcePath,
|
||||
|
||||
@@ -12,60 +12,107 @@ class AppAnnouncementDialog extends StatelessWidget {
|
||||
required this.onDismiss,
|
||||
});
|
||||
|
||||
void _close(BuildContext context) {
|
||||
onDismiss();
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
Future<void> _openCta(BuildContext context) async {
|
||||
final ctaUrl = announcement.ctaUrl;
|
||||
if (ctaUrl == null || ctaUrl.isEmpty) return;
|
||||
if (ctaUrl == null || ctaUrl.isEmpty) {
|
||||
_showCtaOpenFailed(context);
|
||||
return;
|
||||
}
|
||||
|
||||
final uri = Uri.tryParse(ctaUrl);
|
||||
if (uri == null) return;
|
||||
if (uri == null) {
|
||||
_showCtaOpenFailed(context);
|
||||
return;
|
||||
}
|
||||
|
||||
bool launched;
|
||||
try {
|
||||
launched = await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
} catch (_) {
|
||||
launched = false;
|
||||
}
|
||||
if (!launched) {
|
||||
_showCtaOpenFailed(context);
|
||||
return;
|
||||
}
|
||||
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
onDismiss();
|
||||
if (context.mounted) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
}
|
||||
|
||||
void _showCtaOpenFailed(BuildContext context) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
|
||||
const SnackBar(content: Text('Unable to open link. Please try again.')),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isUrgent = announcement.priority.toLowerCase() == 'high';
|
||||
|
||||
return AlertDialog(
|
||||
icon: Icon(
|
||||
isUrgent ? Icons.priority_high_rounded : Icons.campaign_rounded,
|
||||
color: isUrgent ? colorScheme.error : colorScheme.primary,
|
||||
),
|
||||
title: Text(announcement.title),
|
||||
content: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 260),
|
||||
child: SingleChildScrollView(
|
||||
child: Text(
|
||||
announcement.message,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(height: 1.45),
|
||||
// The notice can only be closed through an explicit affordance: never by
|
||||
// tapping the scrim or the system back button (handled by the barrier and
|
||||
// PopScope below). Always keep at least one way out (the X). A
|
||||
// non-dismissible announcement may omit the X only when it carries a CTA,
|
||||
// so the user can never get permanently trapped.
|
||||
final showCloseButton = announcement.dismissible || !announcement.hasCta;
|
||||
|
||||
final actions = <Widget>[
|
||||
if (announcement.hasCta)
|
||||
FilledButton(
|
||||
onPressed: () => _openCta(context),
|
||||
child: Text(announcement.ctaLabel!),
|
||||
),
|
||||
];
|
||||
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
child: AlertDialog(
|
||||
title: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
isUrgent ? Icons.priority_high_rounded : Icons.campaign_rounded,
|
||||
color: isUrgent ? colorScheme.error : colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Text(announcement.title),
|
||||
),
|
||||
),
|
||||
if (showCloseButton)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
tooltip: MaterialLocalizations.of(context).closeButtonTooltip,
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: () => _close(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
content: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 260),
|
||||
child: SingleChildScrollView(
|
||||
child: Text(
|
||||
announcement.message,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(height: 1.45),
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: actions.isEmpty ? null : actions,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
onDismiss();
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text(
|
||||
announcement.dismissible
|
||||
? MaterialLocalizations.of(context).closeButtonLabel
|
||||
: 'OK',
|
||||
),
|
||||
),
|
||||
if (announcement.hasCta)
|
||||
FilledButton(
|
||||
onPressed: () => _openCta(context),
|
||||
child: Text(announcement.ctaLabel!),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -75,10 +122,14 @@ Future<void> showAppAnnouncementDialog(
|
||||
required RemoteAnnouncement announcement,
|
||||
required VoidCallback onDismiss,
|
||||
}) {
|
||||
// barrierDismissible is false so a stray tap outside the dialog can no longer
|
||||
// close (and silently mark-as-seen) the notice. Dismissal — and the
|
||||
// mark-as-seen side effect in onDismiss — only happens via the explicit close
|
||||
// button or the CTA, both of which call onDismiss themselves.
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: announcement.dismissible,
|
||||
barrierDismissible: false,
|
||||
builder: (context) =>
|
||||
AppAnnouncementDialog(announcement: announcement, onDismiss: onDismiss),
|
||||
).whenComplete(onDismiss);
|
||||
);
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@ void main() {
|
||||
);
|
||||
expect(
|
||||
base.copyWith(errorType: DownloadErrorType.rateLimit).errorMessage,
|
||||
'Rate limit reached, try again later',
|
||||
'Service rate limit reached. Wait before retrying.',
|
||||
);
|
||||
expect(
|
||||
base.copyWith(errorType: DownloadErrorType.network).errorMessage,
|
||||
|
||||
Reference in New Issue
Block a user