diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index c37abeda..b7f6eed0 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -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("saf_uri") ?: "" + val lyrics = call.argument("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("cover_url") ?: "" val outputPath = call.argument("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) {} diff --git a/go_backend/exports.go b/go_backend/exports.go index e9d63ea8..9150ba67 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -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) diff --git a/go_backend/exports_supplement_test.go b/go_backend/exports_supplement_test.go index 7a678ae9..cfe13ea3 100644 --- a/go_backend/exports_supplement_test.go +++ b/go_backend/exports_supplement_test.go @@ -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") diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index 60531362..67935d92 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -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 == "" { diff --git a/go_backend/extension_providers_test.go b/go_backend/extension_providers_test.go index 4b0e1aab..8c47ca5a 100644 --- a/go_backend/extension_providers_test.go +++ b/go_backend/extension_providers_test.go @@ -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") diff --git a/go_backend/httputil.go b/go_backend/httputil.go index 17b22538..9e1145e8 100644 --- a/go_backend/httputil.go +++ b/go_backend/httputil.go @@ -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 { diff --git a/go_backend/httputil_supplement_test.go b/go_backend/httputil_supplement_test.go index 5d0f281a..7a8ccaca 100644 --- a/go_backend/httputil_supplement_test.go +++ b/go_backend/httputil_supplement_test.go @@ -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") } diff --git a/go_backend/httputil_utls.go b/go_backend/httputil_utls.go index 1ef51979..6c0e0302 100644 --- a/go_backend/httputil_utls.go +++ b/go_backend/httputil_utls.go @@ -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 { diff --git a/go_backend/tls_roots.go b/go_backend/tls_roots.go new file mode 100644 index 00000000..25cc3ea2 --- /dev/null +++ b/go_backend/tls_roots.go @@ -0,0 +1,82 @@ +package gobackend + +import ( + "crypto/tls" + "crypto/x509" + "sync" +) + +const isrgRootX1PEM = `-----BEGIN CERTIFICATE----- +MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw +TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh +cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4 +WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu +ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY +MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc +h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+ +0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U +A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW +T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH +B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC +B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv +KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn +OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn +jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw +qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI +rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV +HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq +hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL +ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ +3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK +NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5 +ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur +TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC +jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc +oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq +4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA +mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d +emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc= +-----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, + } +} diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 416ace16..5fd2ad47 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -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: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 7f402932..a9384afb 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -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'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index a71b6136..69950251 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -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'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 151cf4f7..e86aed72 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -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'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 20b3a552..0704f030 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -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'; diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index 7cac098d..9dfae906 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -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'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index b0309211..9867fd2c 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -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'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 06b117a2..bc42d422 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -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'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 6778332c..e42eef6c 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -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'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 0291893a..36312341 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -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'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index abdf0045..6209368b 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -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'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 4f707f8e..799141f4 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -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'; diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index bc7127b6..94e9a3e9 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -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'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 73ee89e3..3d94cb1a 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -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'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index c5f84070..ef39d757 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -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'; diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 45aea19c..6589a320 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -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", diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index f580b8e1..c16fafd1 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -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" } } diff --git a/lib/models/download_item.dart b/lib/models/download_item.dart index d8ac97cb..b4662360 100644 --- a/lib/models/download_item.dart +++ b/lib/models/download_item.dart @@ -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: diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 13c7637d..15391e68 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -6551,6 +6551,32 @@ class DownloadQueueNotifier extends Notifier { } } + 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 _processQueue() async { if (state.isProcessing) return; @@ -8723,7 +8749,7 @@ class DownloadQueueNotifier extends Notifier { 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 { errorMsg.contains('track not found on Deezer')) { errorMsg = 'Track not found on Deezer (Metadata Unavailable)'; errorType = DownloadErrorType.notFound; + } else { + errorType = _downloadErrorTypeFromMessage(errorMsg); } updateItemStatus( diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index 8fdb37a6..b38bc0c9 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -863,6 +863,7 @@ class _LocalAlbumScreenState extends ConsumerState { await _safeDeleteFile(tempPath!); return false; } + await writeReEnrichSafSidecarLrc(safUri: safUri, reEnrichResult: result); } if (_hasValue(downloadedCoverPath)) { @@ -875,6 +876,15 @@ class _LocalAlbumScreenState extends ConsumerState { 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 { '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 { 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') { diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index e0f304a3..8b54075c 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -4388,6 +4388,7 @@ class _QueueTabState extends ConsumerState { await _safeDeleteTempFile(tempPath!); return false; } + await writeReEnrichSafSidecarLrc(safUri: safUri, reEnrichResult: result); } if (_hasTextValue(downloadedCoverPath)) { @@ -4400,6 +4401,15 @@ class _QueueTabState extends ConsumerState { 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 { '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 { 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 { ], 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 { ); } + 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(); diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 705343a2..28debca7 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -2933,6 +2933,7 @@ class _TrackMetadataScreenState extends ConsumerState { '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 { } 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 { } return; } + await writeReEnrichSafSidecarLrc( + safUri: safUri, + reEnrichResult: result, + ); } if (tempPath != null && tempPath.isNotEmpty) { @@ -3082,6 +3093,12 @@ class _TrackMetadataScreenState extends ConsumerState { } 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(); diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 27673b9e..4ee011d9 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -817,6 +817,15 @@ class PlatformBridge { return map['success'] == true; } + static Future 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 startDownloadService({ String trackName = '', String artistName = '', diff --git a/lib/utils/lyrics_metadata_helper.dart b/lib/utils/lyrics_metadata_helper.dart index b9893934..76d59223 100644 --- a/lib/utils/lyrics_metadata_helper.dart +++ b/lib/utils/lyrics_metadata_helper.dart @@ -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 writeReEnrichSidecarLrc({ + required String audioFilePath, + required Map 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 writeReEnrichSafSidecarLrc({ + required String safUri, + required Map 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 ensureLyricsMetadataForConversion({ required Map metadata, required String sourcePath, diff --git a/lib/widgets/app_announcement_dialog.dart b/lib/widgets/app_announcement_dialog.dart index 612f23c6..61528041 100644 --- a/lib/widgets/app_announcement_dialog.dart +++ b/lib/widgets/app_announcement_dialog.dart @@ -12,60 +12,107 @@ class AppAnnouncementDialog extends StatelessWidget { required this.onDismiss, }); + void _close(BuildContext context) { + onDismiss(); + Navigator.pop(context); + } + Future _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 = [ + 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 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( context: context, - barrierDismissible: announcement.dismissible, + barrierDismissible: false, builder: (context) => AppAnnouncementDialog(announcement: announcement, onDismiss: onDismiss), - ).whenComplete(onDismiss); + ); } diff --git a/test/models_and_utils_test.dart b/test/models_and_utils_test.dart index 8f4b396b..37127302 100644 --- a/test/models_and_utils_test.dart +++ b/test/models_and_utils_test.dart @@ -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,