Compare commits

...

13 Commits

Author SHA1 Message Date
zarzet 1ce66b9e03 fix: align ios deployment target for file picker 2026-06-02 01:09:53 +07:00
zarzet 8e68af79aa fix: prevent queue header action clipping 2026-06-02 00:58:43 +07:00
zarzet 6246e6e821 chore: update flutter and native dependencies 2026-06-02 00:58:42 +07:00
zarzet 421d5ffdc8 feat: polish search empty state and share caching 2026-06-02 00:58:42 +07:00
zarzet b82dabe316 fix: align cross-service sharing and fallback routing 2026-06-02 00:58:42 +07:00
zarzet ffdaf14ba5 feat: rebuild cross-extension sharing and queue controls
Co-authored-by: Amonoman <musaauron87@gmail.com>
2026-06-02 00:58:41 +07:00
zarzet f52527a41b chore: bump version to 4.5.6 (build 133) 2026-06-02 00:58:41 +07:00
zarzet 56a89c5fc6 fix: harden download errors and re-enrich sidecars 2026-06-02 00:58:40 +07:00
zarzet 4f5163be01 fix: resolve album-only autofill and placeholder re-enrich regressions
- Dart: _metadataMatchIsConfident now handles album-only case (title empty)
  by adding albumMatches fallback branch
- Go: selectBestReEnrichTrack treats placeholder values (Unknown Title,
  Unknown Artist) as empty via isPlaceholderReEnrichValue, so album-based
  fallback filtering works correctly
- Add test for placeholder album fallback in selectBestReEnrichTrack
2026-06-02 00:58:40 +07:00
zarzet 822c094c8c fix: stricter metadata matching, respect embedLyrics setting, improve Apple Music lyrics
- Re-enrich: reject candidates that don't match title/artist/album unless exact ISRC match
- Respect settings.embedLyrics instead of hardcoding true in re-enrich flows
- Skip lyrics resolution in NativeDownloadFinalizer when not needed
- Apple Music lyrics: use direct catalog API with token scraping instead of Paxsenix search
- Support ELRC/ELRCMultiPerson/Plain formats in Apple Music lyrics response
- Add confidence check in metadata auto-fill to prevent applying wrong metadata
- Add tests for stricter re-enrich matching logic
2026-06-02 00:58:40 +07:00
github-actions[bot] 0952b76e11 chore: update AltStore source to v4.5.5 2026-05-14 23:25:38 +00:00
zarzet 7291dbd9e2 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	apps.json
2026-05-15 06:11:51 +07:00
github-actions[bot] d005e2e2e7 chore: update AltStore source to v4.5.1 2026-05-07 18:22:36 +00:00
73 changed files with 3956 additions and 529 deletions
+3 -7
View File
@@ -9,6 +9,9 @@
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
plugins:
riverpod_lint: 3.1.4-dev.3
analyzer:
exclude:
- build/**
@@ -19,9 +22,6 @@ analyzer:
strict-casts: true
strict-inference: true
strict-raw-types: true
plugins:
- custom_lint
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
@@ -44,9 +44,5 @@ linter:
cancel_subscriptions: true
close_sinks: true
custom_lint:
rules:
- avoid_public_notifier_properties
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
+2 -2
View File
@@ -120,8 +120,8 @@ dependencies {
// Include all AAR and JAR files from libs folder
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar"))))
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.11.0-beta02")
implementation("androidx.documentfile:documentfile:1.1.0")
implementation("androidx.activity:activity-ktx:1.13.0")
implementation("com.antonkarpenko:ffmpeg-kit-full:2.1.0")
@@ -690,7 +690,8 @@ class DownloadService : Service() {
request.itemId,
request.requestJson,
request.itemJson,
result
result,
settingsJson
) {
nativeWorkerCancelRequested ||
nativeWorkerPaused ||
@@ -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) {}
@@ -3095,6 +3157,17 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
"findCollectionAcrossExtensions" -> {
val requestJson = call.arguments as? String ?: "{}"
val response: String = withContext(Dispatchers.IO) {
val method = Gobackend::class.java.getMethod(
"findCollectionAcrossExtensionsJSON",
String::class.java
)
method.invoke(null, requestJson) as? String ?: "[]"
}
result.success(response)
}
"enrichTrackWithExtension" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val trackJson = call.argument<String>("track") ?: "{}"
@@ -146,6 +146,7 @@ object NativeDownloadFinalizer {
requestJson: String,
itemJson: String,
result: JSONObject,
settingsJson: String = "{}",
shouldCancel: () -> Boolean = { false },
): JSONObject {
if (!result.optBoolean("success", false)) return result
@@ -217,15 +218,20 @@ object NativeDownloadFinalizer {
refreshFinalAudioQualityMetadata(context, result, state)
}
val history = buildHistoryRow(effectiveInput, state)
upsertHistory(context, history)
val saveDownloadHistory = parseObject(settingsJson)
.optBoolean("save_download_history", true)
val history = if (saveDownloadHistory) {
buildHistoryRow(effectiveInput, state).also { upsertHistory(context, it) }
} else {
null
}
result.put("file_path", state.filePath)
if (state.fileName.isNotBlank()) result.put("file_name", state.fileName)
if (state.quality.isNotBlank()) result.put("quality", state.quality)
result.put("native_finalized", true)
result.put("history_written", true)
result.put("history_item", historyToJson(history))
result.put("history_written", history != null)
if (history != null) result.put("history_item", historyToJson(history))
} catch (e: CancellationException) {
cleanupFailedFinalizationOutput(context, result, initialPath, state.filePath)
result.put("success", false)
@@ -1081,10 +1087,11 @@ object NativeDownloadFinalizer {
val genre = resultString(input, "genre").ifBlank { requestString(input, "genre") }
val label = resultString(input, "label").ifBlank { requestString(input, "label") }
val copyright = resultString(input, "copyright").ifBlank { requestString(input, "copyright") }
val lyrics = resolveLyricsLrc(input)
val shouldEmbedLyrics = input.request.optBoolean("embed_lyrics", false) &&
(input.request.optString("lyrics_mode", "embed") == "embed" ||
input.request.optString("lyrics_mode", "embed") == "both") &&
val lyricsMode = input.request.optString("lyrics_mode", "embed")
val shouldResolveLyrics = input.request.optBoolean("embed_lyrics", false) &&
(lyricsMode == "embed" || lyricsMode == "both")
val lyrics = if (shouldResolveLyrics) resolveLyricsLrc(input) else ""
val shouldEmbedLyrics = shouldResolveLyrics &&
lyrics.isNotBlank() &&
lyrics != "[instrumental:true]"
if (format == "flac") {
+4
View File
@@ -1,2 +1,6 @@
org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false
+1 -1
View File
@@ -19,7 +19,7 @@ pluginManagement {
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.13.2" apply false
id("com.android.application") version "9.2.1" apply false
id("org.jetbrains.kotlin.android") version "2.3.21" apply false
}
+1 -1
View File
@@ -12,7 +12,7 @@
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v4.5.5/SpotiFLAC-v4.5.5-ios-unsigned.ipa",
"localizedDescription": "SpotiFLAC Mobile is written in Flutter. Download tracks in true FLAC from Tidal, Qobuz, & Amazon Music.",
"iconURL": "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/main/assets/images/logo.png",
"size": 37191956
"size": 34915749
}
]
}
+442
View File
@@ -0,0 +1,442 @@
package gobackend
import (
"encoding/json"
"sort"
"strings"
"sync"
)
type CrossExtensionShareResult struct {
ExtensionID string `json:"extension_id"`
DisplayName string `json:"display_name"`
Found bool `json:"found"`
URL string `json:"url,omitempty"`
ItemName string `json:"item_name,omitempty"`
ItemArtists string `json:"item_artists,omitempty"`
Error string `json:"error,omitempty"`
}
var crossExtensionShareResultCache = struct {
sync.RWMutex
entries map[string]string
order []string
}{
entries: make(map[string]string),
}
const crossExtensionShareResultCacheLimit = 128
func FindCollectionAcrossExtensionsJSON(requestJSON string) (string, error) {
var req struct {
Name string `json:"name"`
Artists string `json:"artists"`
Type string `json:"type"`
SourceExtensionID string `json:"source_extension_id"`
}
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
return "", err
}
req.Name = strings.TrimSpace(req.Name)
req.Artists = strings.TrimSpace(req.Artists)
req.Type = strings.ToLower(strings.TrimSpace(req.Type))
req.SourceExtensionID = strings.TrimSpace(req.SourceExtensionID)
if req.Name == "" {
return "[]", nil
}
if req.Type == "" {
req.Type = "album"
}
providers := getExtensionManager().GetMetadataProviders()
work := make([]*extensionProviderWrapper, 0, len(providers))
for _, provider := range providers {
if provider == nil || provider.extension == nil {
continue
}
if provider.extension.ID == req.SourceExtensionID {
continue
}
work = append(work, provider)
}
cacheKey := crossExtensionShareCacheKey(req.Name, req.Artists, req.Type, req.SourceExtensionID, work)
if cached := getCrossExtensionShareCache(cacheKey); cached != "" {
return cached, nil
}
query := req.Name
if req.Artists != "" {
query += " " + req.Artists
}
results := make([]CrossExtensionShareResult, len(work))
var wg sync.WaitGroup
for i, provider := range work {
wg.Add(1)
go func(index int, p *extensionProviderWrapper) {
defer wg.Done()
results[index] = findCollectionForExtension(
p,
req.Type,
req.Name,
req.Artists,
query,
)
}(i, provider)
}
wg.Wait()
data, err := json.Marshal(results)
if err != nil {
return "[]", err
}
response := string(data)
if crossExtensionShareResultsCacheable(results) {
setCrossExtensionShareCache(cacheKey, response)
}
return response, nil
}
func crossExtensionShareCacheKey(name string, artists string, itemType string, sourceExtensionID string, providers []*extensionProviderWrapper) string {
providerKeys := make([]string, 0, len(providers))
for _, provider := range providers {
if provider == nil || provider.extension == nil {
continue
}
ext := provider.extension
displayName := ""
if ext.Manifest != nil {
displayName = ext.Manifest.DisplayName
}
providerKeys = append(providerKeys, strings.Join([]string{
strings.TrimSpace(ext.ID),
strings.TrimSpace(displayName),
strings.TrimSpace(ext.SourceDir),
}, "\x1f"))
}
sort.Strings(providerKeys)
return strings.Join([]string{
normalizeLooseTitle(itemType),
normalizeLooseTitle(name),
normalizeLooseArtistName(artists),
strings.TrimSpace(sourceExtensionID),
strings.Join(providerKeys, "\x1e"),
}, "\x1d")
}
func getCrossExtensionShareCache(key string) string {
if key == "" {
return ""
}
crossExtensionShareResultCache.RLock()
defer crossExtensionShareResultCache.RUnlock()
return crossExtensionShareResultCache.entries[key]
}
func setCrossExtensionShareCache(key string, value string) {
if key == "" || value == "" {
return
}
crossExtensionShareResultCache.Lock()
defer crossExtensionShareResultCache.Unlock()
if _, exists := crossExtensionShareResultCache.entries[key]; !exists {
crossExtensionShareResultCache.order = append(crossExtensionShareResultCache.order, key)
}
crossExtensionShareResultCache.entries[key] = value
for len(crossExtensionShareResultCache.order) > crossExtensionShareResultCacheLimit {
oldest := crossExtensionShareResultCache.order[0]
crossExtensionShareResultCache.order = crossExtensionShareResultCache.order[1:]
delete(crossExtensionShareResultCache.entries, oldest)
}
}
func crossExtensionShareResultsCacheable(results []CrossExtensionShareResult) bool {
for _, result := range results {
if result.Found {
continue
}
errText := strings.ToLower(strings.TrimSpace(result.Error))
if errText == "" ||
errText == "no results" ||
errText == "unsupported collection type" ||
strings.HasSuffix(errText, " not found") ||
strings.Contains(errText, "found without shareable link") {
continue
}
return false
}
return true
}
func findCollectionForExtension(
provider *extensionProviderWrapper,
itemType string,
name string,
artists string,
query string,
) CrossExtensionShareResult {
result := CrossExtensionShareResult{
ExtensionID: provider.extension.ID,
}
if provider.extension.Manifest != nil {
result.DisplayName = provider.extension.Manifest.DisplayName
}
if result.DisplayName == "" {
result.DisplayName = provider.extension.ID
}
searchResult, err := searchCollectionCandidates(provider, itemType, query)
if err != nil {
result.Error = err.Error()
return result
}
if searchResult == nil || len(searchResult.Tracks) == 0 {
result.Error = "no results"
return result
}
var best *ExtTrackMetadata
switch itemType {
case "artist":
best = bestArtistTrack(searchResult.Tracks, name)
case "album":
best = bestAlbumTrack(searchResult.Tracks, name, artists)
default:
result.Error = "unsupported collection type"
return result
}
if best == nil {
result.Error = itemType + " not found"
return result
}
url := resolveCollectionShareURL(provider.extension, itemType, best)
if url == "" {
result.Error = itemType + " found without shareable link"
return result
}
result.Found = true
result.URL = url
if itemType == "artist" {
result.ItemName = collectionArtistName(*best)
} else {
result.ItemName = collectionAlbumName(*best)
result.ItemArtists = best.Artists
}
return result
}
func searchCollectionCandidates(provider *extensionProviderWrapper, itemType string, query string) (*ExtSearchResult, error) {
filter := ""
switch itemType {
case "album":
filter = "albums"
case "artist":
filter = "artists"
}
if filter != "" {
tracks, err := provider.CustomSearch(query, map[string]interface{}{
"filter": filter,
"limit": 10,
})
if err == nil && len(tracks) > 0 {
return &ExtSearchResult{Tracks: tracks, Total: len(tracks)}, nil
}
}
return provider.SearchTracks(query, 10)
}
func bestAlbumTrack(tracks []ExtTrackMetadata, albumName string, artists string) *ExtTrackMetadata {
targetAlbum := normalizeLooseTitle(albumName)
targetArtists := normalizeLooseArtistName(artists)
bestScore := 0
bestIndex := -1
for i := range tracks {
track := tracks[i]
album := normalizeLooseTitle(collectionAlbumName(track))
trackArtists := normalizeLooseArtistName(track.Artists + " " + track.AlbumArtist)
score := 0
if isCollectionItemType(track, "album") {
score += 25
}
if album == targetAlbum {
score += 100
} else if album != "" && targetAlbum != "" && (strings.Contains(album, targetAlbum) || strings.Contains(targetAlbum, album)) {
score += 50
}
if targetArtists != "" && (strings.Contains(trackArtists, targetArtists) || strings.Contains(targetArtists, trackArtists)) {
score += 30
}
if score > bestScore {
bestScore = score
bestIndex = i
}
}
if bestIndex < 0 || bestScore < 50 {
return nil
}
return &tracks[bestIndex]
}
func bestArtistTrack(tracks []ExtTrackMetadata, artistName string) *ExtTrackMetadata {
targetArtist := normalizeLooseArtistName(artistName)
bestScore := 0
bestIndex := -1
for i := range tracks {
artist := normalizeLooseArtistName(collectionArtistName(tracks[i]))
score := 0
if isCollectionItemType(tracks[i], "artist") {
score += 25
}
if artist == targetArtist {
score += 100
} else if artist != "" && targetArtist != "" && (strings.Contains(artist, targetArtist) || strings.Contains(targetArtist, artist)) {
score += 60
}
if score > bestScore {
bestScore = score
bestIndex = i
}
}
if bestIndex < 0 || bestScore < 60 {
return nil
}
return &tracks[bestIndex]
}
func resolveCollectionShareURL(ext *loadedExtension, itemType string, track *ExtTrackMetadata) string {
if track == nil {
return ""
}
if itemType == "album" {
if isCollectionItemType(*track, "album") {
if url := normalizeShareURL(track.ExternalURL); url != "" {
return url
}
}
if url := normalizeShareURL(track.AlbumURL); url != "" {
return url
}
if url := urlFromExternalLinks(track.ExternalLinks, "album"); url != "" {
return url
}
if url := templateShareURL(ext, "album", firstNonEmptyString(track.AlbumID, collectionID(*track, "album"), track.AlbumURL)); url != "" {
return url
}
return ""
}
if isCollectionItemType(*track, "artist") {
if url := normalizeShareURL(track.ExternalURL); url != "" {
return url
}
}
if url := normalizeShareURL(track.ArtistURL); url != "" {
return url
}
if url := urlFromExternalLinks(track.ExternalLinks, "artist"); url != "" {
return url
}
if url := templateShareURL(ext, "artist", firstNonEmptyString(track.ArtistID, collectionID(*track, "artist"))); url != "" {
return url
}
return ""
}
func collectionAlbumName(track ExtTrackMetadata) string {
if isCollectionItemType(track, "album") {
return track.Name
}
return track.AlbumName
}
func collectionArtistName(track ExtTrackMetadata) string {
if isCollectionItemType(track, "artist") {
return track.Name
}
return track.Artists
}
func collectionID(track ExtTrackMetadata, itemType string) string {
if isCollectionItemType(track, itemType) {
return track.ID
}
return ""
}
func isCollectionItemType(track ExtTrackMetadata, itemType string) bool {
return strings.EqualFold(strings.TrimSpace(track.ItemType), itemType)
}
func normalizeShareURL(value string) string {
trimmed := strings.TrimSpace(value)
if strings.HasPrefix(trimmed, "http://") || strings.HasPrefix(trimmed, "https://") {
return trimmed
}
return ""
}
func urlFromExternalLinks(links map[string]string, preferredKey string) string {
for key, value := range links {
if strings.Contains(strings.ToLower(key), preferredKey) {
if url := normalizeShareURL(value); url != "" {
return url
}
}
}
return ""
}
func templateShareURL(ext *loadedExtension, itemType string, id string) string {
if ext == nil || ext.Manifest == nil || ext.Manifest.Capabilities == nil {
return ""
}
id = stripProviderPrefix(strings.TrimSpace(id))
if id == "" {
return ""
}
templates, ok := ext.Manifest.Capabilities["shareUrlTemplates"].(map[string]interface{})
if !ok {
return ""
}
rawTemplate, ok := templates[itemType].(string)
if !ok {
return ""
}
rawTemplate = strings.TrimSpace(rawTemplate)
if rawTemplate == "" {
return ""
}
return strings.ReplaceAll(rawTemplate, "{id}", id)
}
func stripProviderPrefix(id string) string {
if index := strings.Index(id, ":"); index > 0 && index < len(id)-1 {
return id[index+1:]
}
return id
}
func firstNonEmptyString(values ...string) string {
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed != "" {
return trimmed
}
}
return ""
}
+100
View File
@@ -0,0 +1,100 @@
package gobackend
import "testing"
func TestCrossExtensionShareUsesAlbumCollectionItems(t *testing.T) {
ext := &loadedExtension{
Manifest: &ExtensionManifest{
Capabilities: map[string]interface{}{
"shareUrlTemplates": map[string]interface{}{
"album": "https://music.apple.com/us/album/{id}",
},
},
},
}
tracks := []ExtTrackMetadata{
{
ID: "1440783617",
Name: "Nevermind",
Artists: "Nirvana",
ItemType: "album",
},
}
best := bestAlbumTrack(tracks, "Nevermind", "Nirvana")
if best == nil {
t.Fatal("expected album collection item to match")
}
if url := resolveCollectionShareURL(ext, "album", best); url != "https://music.apple.com/us/album/1440783617" {
t.Fatalf("album share URL = %q", url)
}
}
func TestCrossExtensionShareUsesArtistCollectionItems(t *testing.T) {
ext := &loadedExtension{
Manifest: &ExtensionManifest{
Capabilities: map[string]interface{}{
"shareUrlTemplates": map[string]interface{}{
"artist": "https://music.youtube.com/browse/{id}",
},
},
},
}
tracks := []ExtTrackMetadata{
{
ID: "UCrPe3hLA51968GwxHSZ1llw",
Name: "Nirvana",
ItemType: "artist",
},
}
best := bestArtistTrack(tracks, "Nirvana")
if best == nil {
t.Fatal("expected artist collection item to match")
}
if url := resolveCollectionShareURL(ext, "artist", best); url != "https://music.youtube.com/browse/UCrPe3hLA51968GwxHSZ1llw" {
t.Fatalf("artist share URL = %q", url)
}
}
func TestCrossExtensionShareCacheKeyIsProviderOrderStable(t *testing.T) {
apple := &extensionProviderWrapper{
extension: &loadedExtension{
ID: "apple",
SourceDir: "/extensions/apple",
Manifest: &ExtensionManifest{DisplayName: "Apple Music"},
},
}
qobuz := &extensionProviderWrapper{
extension: &loadedExtension{
ID: "qobuz",
SourceDir: "/extensions/qobuz",
Manifest: &ExtensionManifest{DisplayName: "Qobuz"},
},
}
first := crossExtensionShareCacheKey("Nevermind", "Nirvana", "album", "spotify", []*extensionProviderWrapper{apple, qobuz})
second := crossExtensionShareCacheKey("Nevermind", "Nirvana", "album", "spotify", []*extensionProviderWrapper{qobuz, apple})
if first != second {
t.Fatalf("cache key should not depend on provider order:\n%s\n%s", first, second)
}
}
func TestCrossExtensionShareCacheableSkipsTransientErrors(t *testing.T) {
cacheable := []CrossExtensionShareResult{
{ExtensionID: "apple", Found: true, URL: "https://music.apple.com/us/album/1"},
{ExtensionID: "qobuz", Error: "album not found"},
{ExtensionID: "tidal", Error: "no results"},
}
if !crossExtensionShareResultsCacheable(cacheable) {
t.Fatal("expected found and deterministic not-found results to be cacheable")
}
transient := []CrossExtensionShareResult{
{ExtensionID: "apple", Found: true, URL: "https://music.apple.com/us/album/1"},
{ExtensionID: "qobuz", Error: "request failed: timeout"},
}
if crossExtensionShareResultsCacheable(transient) {
t.Fatal("expected transient extension errors to skip cache")
}
}
+104 -38
View File
@@ -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
}
@@ -594,12 +610,24 @@ func selectBestReEnrichTrack(req reEnrichRequest, tracks []ExtTrackMetadata) *Ex
downloadReq := reEnrichDownloadRequest(req)
currentISRC := strings.TrimSpace(req.ISRC)
currentAlbum := strings.TrimSpace(req.AlbumName)
effectiveTrackName := req.TrackName
if isPlaceholderReEnrichValue(effectiveTrackName) {
effectiveTrackName = ""
}
effectiveArtistName := req.ArtistName
if isPlaceholderReEnrichValue(effectiveArtistName) {
effectiveArtistName = ""
}
var best *ExtTrackMetadata
bestScore := -1 << 30
for i := range tracks {
track := &tracks[i]
score := 0
exactISRCMatch := currentISRC != "" && strings.EqualFold(currentISRC, strings.TrimSpace(track.ISRC))
titleMatches := effectiveTrackName != "" && track.Name != "" && titlesMatch(effectiveTrackName, track.Name)
artistMatches := effectiveArtistName != "" && track.Artists != "" && artistsMatch(effectiveArtistName, track.Artists)
albumMatches := currentAlbum != "" && track.AlbumName != "" && titlesMatch(currentAlbum, track.AlbumName)
resolved := resolvedTrackInfo{
Title: track.Name,
@@ -607,22 +635,39 @@ func selectBestReEnrichTrack(req reEnrichRequest, tracks []ExtTrackMetadata) *Ex
ISRC: track.ISRC,
Duration: track.DurationMS / 1000,
}
if trackMatchesRequest(downloadReq, resolved, "ReEnrich") {
verified := trackMatchesRequest(downloadReq, resolved, "ReEnrich")
if !exactISRCMatch {
if effectiveTrackName != "" && !titleMatches {
continue
}
if effectiveArtistName != "" && !artistMatches {
continue
}
if effectiveTrackName == "" && effectiveArtistName == "" && currentAlbum != "" && !albumMatches {
continue
}
if effectiveTrackName == "" && effectiveArtistName == "" && currentAlbum == "" && !verified {
continue
}
}
if verified {
score += 2000
}
if currentISRC != "" && strings.EqualFold(currentISRC, strings.TrimSpace(track.ISRC)) {
if exactISRCMatch {
score += 10000
}
if req.TrackName != "" && track.Name != "" && titlesMatch(req.TrackName, track.Name) {
if titleMatches {
score += 400
}
if req.ArtistName != "" && track.Artists != "" && artistsMatch(req.ArtistName, track.Artists) {
if artistMatches {
score += 320
}
if currentAlbum != "" && track.AlbumName != "" {
switch {
case titlesMatch(currentAlbum, track.AlbumName):
case albumMatches:
score += 120
case strings.Contains(strings.ToLower(track.AlbumName), strings.ToLower(currentAlbum)),
strings.Contains(strings.ToLower(currentAlbum), strings.ToLower(track.AlbumName)):
@@ -1911,6 +1956,11 @@ func normalizeExtensionTrackMetadataMap(
"artists": track.Artists,
"album_name": track.AlbumName,
"album_artist": track.AlbumArtist,
"album_id": track.AlbumID,
"album_url": track.AlbumURL,
"artist_id": track.ArtistID,
"artist_url": track.ArtistURL,
"external_urls": track.ExternalURL,
"duration_ms": track.DurationMS,
"images": coverURL,
"cover_url": coverURL,
@@ -2329,37 +2379,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,
@@ -2370,6 +2390,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")
@@ -2716,7 +2771,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
@@ -2751,6 +2808,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
@@ -2766,6 +2828,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)
+20
View File
@@ -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")
+84
View File
@@ -407,6 +407,90 @@ func TestSelectBestReEnrichTrackPrefersCandidateWithReleaseDate(t *testing.T) {
}
}
func TestSelectBestReEnrichTrackRejectsMismatchedSearchResults(t *testing.T) {
req := reEnrichRequest{
TrackName: "Song Title",
ArtistName: "Artist Name",
AlbumName: "Album Name",
DurationMs: 180000,
}
tracks := []ExtTrackMetadata{
{
ID: "wrong-rich-metadata",
Name: "Different Song",
Artists: "Different Artist",
AlbumName: "Album Name",
DurationMS: 180000,
ReleaseDate: "2024-03-09",
TrackNumber: 4,
DiscNumber: 1,
ISRC: "WRONG1234567",
ProviderID: "deezer",
},
}
if best := selectBestReEnrichTrack(req, tracks); best != nil {
t.Fatalf("selected track = %q, want no match", best.ID)
}
}
func TestSelectBestReEnrichTrackAllowsExactISRCDespiteMetadataMismatch(t *testing.T) {
req := reEnrichRequest{
TrackName: "Song Title",
ArtistName: "Artist Name",
ISRC: "USRC17607839",
DurationMs: 999999000,
}
tracks := []ExtTrackMetadata{
{
ID: "same-isrc",
Name: "Different Song",
Artists: "Different Artist",
DurationMS: 180000,
ISRC: "USRC17607839",
ProviderID: "deezer",
},
}
best := selectBestReEnrichTrack(req, tracks)
if best == nil {
t.Fatal("expected exact ISRC candidate to be selected")
}
if best.ID != "same-isrc" {
t.Fatalf("selected track = %q, want exact ISRC candidate", best.ID)
}
}
func TestSelectBestReEnrichTrackPlaceholderFallsBackToAlbum(t *testing.T) {
req := reEnrichRequest{
TrackName: "Unknown Title",
ArtistName: "Unknown Artist",
AlbumName: "Harry Styles",
DurationMs: 180000,
}
tracks := []ExtTrackMetadata{
{
ID: "album-match",
Name: "Sign of the Times",
Artists: "Harry Styles",
AlbumName: "Harry Styles",
DurationMS: 180000,
ProviderID: "deezer",
},
}
best := selectBestReEnrichTrack(req, tracks)
if best == nil {
t.Fatal("expected album-matching candidate to be selected when title/artist are placeholders")
}
if best.ID != "album-match" {
t.Fatalf("selected track = %q, want album-match", best.ID)
}
}
func TestBuildReEnrichFFmpegMetadataOmitsEmptyFields(t *testing.T) {
req := reEnrichRequest{
TrackName: "Song",
+58
View File
@@ -8,12 +8,14 @@ import (
"net/http"
"net/url"
"strings"
"sync"
"time"
)
const (
extensionHealthDefaultTimeout = 4 * time.Second
extensionHealthMaxBodyBytes = 64 * 1024
extensionHealthDefaultCache = 60 * time.Second
)
type ExtensionHealthResult struct {
@@ -38,6 +40,16 @@ type ExtensionHealthCheckResult struct {
CheckedAt string `json:"checked_at"`
}
type cachedExtensionHealthResult struct {
result ExtensionHealthResult
expiresAt time.Time
}
var (
extensionHealthCacheMu sync.Mutex
extensionHealthCache = map[string]cachedExtensionHealthResult{}
)
func CheckExtensionHealthJSON(extensionID string) (string, error) {
manager := getExtensionManager()
ext, err := manager.GetExtension(extensionID)
@@ -53,6 +65,38 @@ func CheckExtensionHealthJSON(extensionID string) (string, error) {
return string(bytes), nil
}
func CheckExtensionHealthCached(ext *loadedExtension) ExtensionHealthResult {
if ext == nil || ext.Manifest == nil || len(ext.Manifest.ServiceHealth) == 0 {
return CheckExtensionHealth(ext)
}
cacheKey := strings.TrimSpace(ext.ID)
if cacheKey == "" {
return CheckExtensionHealth(ext)
}
now := time.Now()
extensionHealthCacheMu.Lock()
cached, ok := extensionHealthCache[cacheKey]
if ok && now.Before(cached.expiresAt) {
extensionHealthCacheMu.Unlock()
return cached.result
}
extensionHealthCacheMu.Unlock()
result := CheckExtensionHealth(ext)
ttl := extensionHealthCacheTTL(ext.Manifest.ServiceHealth)
extensionHealthCacheMu.Lock()
extensionHealthCache[cacheKey] = cachedExtensionHealthResult{
result: result,
expiresAt: now.Add(ttl),
}
extensionHealthCacheMu.Unlock()
return result
}
func CheckExtensionHealth(ext *loadedExtension) ExtensionHealthResult {
now := time.Now().UTC().Format(time.RFC3339)
result := ExtensionHealthResult{
@@ -98,6 +142,20 @@ func CheckExtensionHealth(ext *loadedExtension) ExtensionHealthResult {
return result
}
func extensionHealthCacheTTL(checks []ExtensionHealthCheck) time.Duration {
ttl := extensionHealthDefaultCache
for _, check := range checks {
if check.CacheTTLSeconds <= 0 {
continue
}
checkTTL := time.Duration(check.CacheTTLSeconds) * time.Second
if checkTTL < ttl {
ttl = checkTTL
}
}
return ttl
}
func runExtensionHealthCheck(manifest *ExtensionManifest, check ExtensionHealthCheck) ExtensionHealthCheckResult {
method := strings.ToUpper(strings.TrimSpace(check.Method))
if method == "" {
+114 -8
View File
@@ -22,6 +22,11 @@ type ExtTrackMetadata struct {
Artists string `json:"artists"`
AlbumName string `json:"album_name"`
AlbumArtist string `json:"album_artist,omitempty"`
AlbumID string `json:"album_id,omitempty"`
AlbumURL string `json:"album_url,omitempty"`
ArtistID string `json:"artist_id,omitempty"`
ArtistURL string `json:"artist_url,omitempty"`
ExternalURL string `json:"external_urls,omitempty"`
DurationMS int `json:"duration_ms"`
CoverURL string `json:"cover_url,omitempty"`
Images string `json:"images,omitempty"`
@@ -377,6 +382,64 @@ func shouldStopProviderFallback(availability *ExtAvailabilityResult) bool {
return availability != nil && availability.SkipFallback
}
func fallbackRuntimeHealthStatus(ext *loadedExtension) string {
if ext == nil || ext.Manifest == nil || len(ext.Manifest.ServiceHealth) == 0 {
return "unknown"
}
status := strings.ToLower(strings.TrimSpace(CheckExtensionHealthCached(ext).Status))
switch status {
case "online", "degraded", "offline":
return status
default:
return "unknown"
}
}
func prioritizeFallbackProvidersByHealth(priority []string, extManager *extensionManager, sourceProvider string) []string {
if len(priority) == 0 || extManager == nil {
return priority
}
online := make([]string, 0, len(priority))
degraded := make([]string, 0, len(priority))
unknown := make([]string, 0, len(priority))
for _, rawProviderID := range priority {
providerID := strings.TrimSpace(rawProviderID)
if providerID == "" {
continue
}
if strings.EqualFold(providerID, sourceProvider) || !isExtensionFallbackAllowed(providerID) {
unknown = append(unknown, providerID)
continue
}
ext, err := extManager.GetExtension(providerID)
if err != nil || ext == nil || !ext.Enabled || ext.Error != "" || ext.Manifest == nil || !ext.Manifest.IsDownloadProvider() {
unknown = append(unknown, providerID)
continue
}
switch fallbackRuntimeHealthStatus(ext) {
case "online":
online = append(online, providerID)
case "degraded":
degraded = append(degraded, providerID)
case "offline":
GoLog("[DownloadWithExtensionFallback] Skipping extension provider %s (service health offline)\n", providerID)
default:
unknown = append(unknown, providerID)
}
}
result := make([]string, 0, len(online)+len(degraded)+len(unknown))
result = append(result, online...)
result = append(result, degraded...)
result = append(result, unknown...)
return result
}
func resolveExtensionAvailabilityReason(availability *ExtAvailabilityResult, err error) string {
if availability != nil {
if reason := strings.TrimSpace(availability.Reason); reason != "" {
@@ -391,10 +454,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,
}
}
@@ -680,6 +747,11 @@ func parseExtensionTrackValue(vm *goja.Runtime, value goja.Value) ExtTrackMetada
Artists: gojaObjectString(obj, "artists"),
AlbumName: gojaObjectString(obj, "album_name", "albumName"),
AlbumArtist: gojaObjectString(obj, "album_artist", "albumArtist"),
AlbumID: gojaObjectString(obj, "album_id", "albumId"),
AlbumURL: gojaObjectString(obj, "album_url", "albumUrl"),
ArtistID: gojaObjectString(obj, "artist_id", "artistId"),
ArtistURL: gojaObjectString(obj, "artist_url", "artistUrl"),
ExternalURL: gojaObjectString(obj, "external_urls", "externalUrls", "external_url", "externalUrl", "url"),
DurationMS: gojaObjectInt(obj, "duration_ms", "durationMs"),
CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"),
Images: gojaObjectString(obj, "images"),
@@ -1786,7 +1858,9 @@ func isRetiredBuiltInDownloadProvider(providerID string) bool {
}
switch normalized {
case "deezer", "qobuz", "tidal":
return true
return !hasEnabledExtensionProvider(normalized, func(manifest *ExtensionManifest) bool {
return manifest.IsDownloadProvider()
})
default:
return false
}
@@ -1799,12 +1873,36 @@ func isRetiredBuiltInMetadataProvider(providerID string) bool {
}
switch normalized {
case "deezer", "spotify", "qobuz", "tidal":
return true
return !hasEnabledExtensionProvider(normalized, func(manifest *ExtensionManifest) bool {
return manifest.IsMetadataProvider()
})
default:
return false
}
}
func hasEnabledExtensionProvider(providerID string, matches func(*ExtensionManifest) bool) bool {
if providerID == "" || matches == nil {
return false
}
manager := getExtensionManager()
manager.mu.RLock()
defer manager.mu.RUnlock()
for id, ext := range manager.extensions {
if !strings.EqualFold(strings.TrimSpace(id), providerID) {
continue
}
if ext == nil || !ext.Enabled || ext.Error != "" || ext.Manifest == nil {
return false
}
return matches(ext.Manifest)
}
return false
}
func SetExtensionFallbackProviderIDs(providerIDs []string) {
extensionFallbackProviderIDsMu.Lock()
defer extensionFallbackProviderIDsMu.Unlock()
@@ -2374,6 +2472,8 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
}
}
priority = prioritizeFallbackProvidersByHealth(priority, extManager, req.Source)
for _, providerID := range priority {
if isDownloadCancelled(req.ItemID) {
return nil, ErrDownloadCancelled
@@ -2519,10 +2619,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 +2661,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 +2720,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 == "" {
+159
View File
@@ -10,6 +10,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
@@ -92,6 +93,125 @@ func TestSetProviderPriorityRemovesRetiredDeezerDownloader(t *testing.T) {
}
}
func TestSetProviderPriorityKeepsExtensionNamedLikeRetiredDownloader(t *testing.T) {
original := GetProviderPriority()
defer SetProviderPriority(original)
manager := getExtensionManager()
ext := newTestLoadedExtension(t, ExtensionTypeDownloadProvider)
ext.ID = "deezer"
ext.Manifest.Name = "deezer"
manager.mu.Lock()
previous, hadPrevious := manager.extensions[ext.ID]
manager.extensions[ext.ID] = ext
manager.mu.Unlock()
defer func() {
manager.mu.Lock()
if hadPrevious {
manager.extensions[ext.ID] = previous
} else {
delete(manager.extensions, ext.ID)
}
manager.mu.Unlock()
}()
SetProviderPriority([]string{"deezer", "custom-ext"})
got := GetProviderPriority()
want := []string{"deezer", "custom-ext"}
if len(got) != len(want) {
t.Fatalf("unexpected priority length: got %v want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("unexpected priority at %d: got %v want %v", i, got, want)
}
}
}
func TestPrioritizeFallbackProvidersByHealthPrefersOnlineAndSkipsOffline(t *testing.T) {
manager := getExtensionManager()
amazon := newTestLoadedExtension(t, ExtensionTypeDownloadProvider)
amazon.ID = "amazon"
amazon.Manifest.Name = "amazon"
amazon.Manifest.ServiceHealth = []ExtensionHealthCheck{{
ID: "main",
URL: "://bad",
Required: true,
}}
plain := newTestLoadedExtension(t, ExtensionTypeDownloadProvider)
plain.ID = "plain"
plain.Manifest.Name = "plain"
deezer := newTestLoadedExtension(t, ExtensionTypeDownloadProvider)
deezer.ID = "deezer"
deezer.Manifest.Name = "deezer"
deezer.Manifest.ServiceHealth = []ExtensionHealthCheck{{
ID: "main",
URL: "https://example.test/health",
}}
manager.mu.Lock()
previousAmazon, hadAmazon := manager.extensions[amazon.ID]
previousPlain, hadPlain := manager.extensions[plain.ID]
previousDeezer, hadDeezer := manager.extensions[deezer.ID]
manager.extensions[amazon.ID] = amazon
manager.extensions[plain.ID] = plain
manager.extensions[deezer.ID] = deezer
manager.mu.Unlock()
defer func() {
manager.mu.Lock()
if hadAmazon {
manager.extensions[amazon.ID] = previousAmazon
} else {
delete(manager.extensions, amazon.ID)
}
if hadPlain {
manager.extensions[plain.ID] = previousPlain
} else {
delete(manager.extensions, plain.ID)
}
if hadDeezer {
manager.extensions[deezer.ID] = previousDeezer
} else {
delete(manager.extensions, deezer.ID)
}
manager.mu.Unlock()
extensionHealthCacheMu.Lock()
delete(extensionHealthCache, deezer.ID)
extensionHealthCacheMu.Unlock()
}()
extensionHealthCacheMu.Lock()
extensionHealthCache[deezer.ID] = cachedExtensionHealthResult{
result: ExtensionHealthResult{
ExtensionID: deezer.ID,
Status: "online",
CheckedAt: time.Now().UTC().Format(time.RFC3339),
},
expiresAt: time.Now().Add(time.Minute),
}
extensionHealthCacheMu.Unlock()
got := prioritizeFallbackProvidersByHealth(
[]string{"amazon", "plain", "deezer"},
manager,
"",
)
want := []string{"deezer", "plain"}
if len(got) != len(want) {
t.Fatalf("unexpected provider order length: got %v want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("unexpected provider order at %d: got %v want %v", i, got, want)
}
}
}
func TestNormalizeDownloadDecryptionInfoPromotesLegacyKey(t *testing.T) {
normalized := normalizeDownloadDecryptionInfo(nil, " 001122 ")
if normalized == nil {
@@ -286,6 +406,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")
+11 -11
View File
@@ -10,20 +10,20 @@ require (
github.com/go-flac/flacvorbis/v2 v2.0.2
github.com/go-flac/go-flac/v2 v2.0.4
github.com/refraction-networking/utls v1.8.2
golang.org/x/crypto v0.50.0
golang.org/x/mobile v0.0.0-20260410095206-2cfb76559b7b
golang.org/x/net v0.53.0
golang.org/x/text v0.36.0
golang.org/x/crypto v0.52.0
golang.org/x/mobile v0.0.0-20260529142300-ecb4cd65260a
golang.org/x/net v0.55.0
golang.org/x/text v0.37.0
)
require (
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/dlclark/regexp2 v1.12.0 // indirect
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc // indirect
github.com/klauspost/compress v1.18.5 // indirect
golang.org/x/mod v0.35.0 // indirect
github.com/google/pprof v0.0.0-20260507013755-92041b743c96 // indirect
github.com/klauspost/compress v1.18.6 // indirect
golang.org/x/mod v0.36.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/tools v0.44.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/tools v0.45.0 // indirect
)
+22 -22
View File
@@ -1,11 +1,11 @@
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c h1:OcLmPfx1T1RmZVHHFwWMPaZDdRf0DBMZOFMVWJa7Pdk=
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE=
@@ -18,10 +18,10 @@ github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TC
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc h1:VBbFa1lDYWEeV5FZKUiYKYT0VxCp9twUmmaq9eb8sXw=
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/google/pprof v0.0.0-20260507013755-92041b743c96 h1:YDDnaZ9afWajDboPMt9Vikqca/yWAX7KAxVzb4lJU1M=
github.com/google/pprof v0.0.0-20260507013755-92041b743c96/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
@@ -30,22 +30,22 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/mobile v0.0.0-20260410095206-2cfb76559b7b h1:Qt2eaXcZ8x20iAcoZ6AceeMMtnjuPHvC51KRCH1DKSQ=
golang.org/x/mobile v0.0.0-20260410095206-2cfb76559b7b/go.mod h1:5Fu78lew5ucMXt8w2KYcwvxu2rkC/liHzUvaoiI+H/M=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/mobile v0.0.0-20260529142300-ecb4cd65260a h1:sEcsLeiCTTaHGWn+v81+PLAOzzOA9wmzNRqr1WfCmVY=
golang.org/x/mobile v0.0.0-20260529142300-ecb4cd65260a/go.mod h1:ltIbhcRzKgwHa4ZxKJeiv0nyzcXUUYCqMyO0Y+vPmXw=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+4 -11
View File
@@ -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 {
+25
View File
@@ -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")
}
+5 -2
View File
@@ -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 {
+213 -23
View File
@@ -7,7 +7,9 @@ import (
"math"
"net/http"
"net/url"
"regexp"
"strings"
"sync"
"time"
)
@@ -15,6 +17,8 @@ type AppleMusicClient struct {
httpClient *http.Client
}
const appleMusicCatalogBaseURL = "https://amp-api.music.apple.com/v1/catalog/us"
type appleMusicSearchResult struct {
ID string `json:"id"`
SongName string `json:"songName"`
@@ -23,9 +27,33 @@ type appleMusicSearchResult struct {
Duration int `json:"duration"`
}
type appleMusicCatalogSearchResponse struct {
Results struct {
Songs *struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
} `json:"songs"`
} `json:"results"`
Resources *struct {
Songs map[string]struct {
Attributes struct {
Name string `json:"name"`
ArtistName string `json:"artistName"`
AlbumName string `json:"albumName"`
DurationInMillis int `json:"durationInMillis"`
} `json:"attributes"`
} `json:"songs"`
} `json:"resources"`
}
type paxResponse struct {
Type string `json:"type"` // "Syllable" or "Line"
Content []paxLyrics `json:"content"` // List of lyric lines
Type string `json:"type"` // "Syllable" or "Line"
Content []paxLyrics `json:"content"`
ELRC string `json:"elrc"`
ELRCMultiPerson string `json:"elrcMultiPerson"`
Plain string `json:"plain"`
TTMLContent string `json:"ttmlContent"`
}
type paxLyrics struct {
@@ -44,6 +72,11 @@ type paxLyricDetail struct {
EndTime *int `json:"endtime"`
}
var (
appleMusicTokenMu sync.Mutex
appleMusicCachedToken string
)
func NewAppleMusicClient() *AppleMusicClient {
return &AppleMusicClient{
httpClient: NewMetadataHTTPClient(20 * time.Second),
@@ -100,36 +133,164 @@ func selectBestAppleMusicSearchResult(results []appleMusicSearchResult, trackNam
return &results[bestIndex]
}
func (c *AppleMusicClient) getAppleMusicToken() (string, error) {
appleMusicTokenMu.Lock()
defer appleMusicTokenMu.Unlock()
if appleMusicCachedToken != "" {
return appleMusicCachedToken, nil
}
req, err := http.NewRequest("GET", "https://beta.music.apple.com", nil)
if err != nil {
return "", fmt.Errorf("failed to create apple music page request: %w", err)
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
resp, err := c.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("failed to fetch apple music page: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("apple music page returned HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read apple music page: %w", err)
}
indexPath := regexp.MustCompile(`/assets/index~[^"' <]+\.js`).FindString(string(body))
if indexPath == "" {
return "", fmt.Errorf("apple music index script not found")
}
jsReq, err := http.NewRequest("GET", "https://beta.music.apple.com"+indexPath, nil)
if err != nil {
return "", fmt.Errorf("failed to create apple music script request: %w", err)
}
jsReq.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
jsResp, err := c.httpClient.Do(jsReq)
if err != nil {
return "", fmt.Errorf("failed to fetch apple music script: %w", err)
}
defer jsResp.Body.Close()
if jsResp.StatusCode != http.StatusOK {
return "", fmt.Errorf("apple music script returned HTTP %d", jsResp.StatusCode)
}
jsBody, err := io.ReadAll(jsResp.Body)
if err != nil {
return "", fmt.Errorf("failed to read apple music script: %w", err)
}
token := regexp.MustCompile(`eyJh[^"' <]+`).FindString(string(jsBody))
if token == "" {
return "", fmt.Errorf("apple music token not found")
}
appleMusicCachedToken = token
return token, nil
}
func clearAppleMusicToken() {
appleMusicTokenMu.Lock()
defer appleMusicTokenMu.Unlock()
appleMusicCachedToken = ""
}
func (c *AppleMusicClient) searchSongWithToken(token, query string) ([]appleMusicSearchResult, error) {
params := url.Values{}
params.Set("term", query)
params.Set("types", "songs")
params.Set("limit", "25")
params.Set("l", "en-US")
params.Set("platform", "web")
params.Set("format[resources]", "map")
params.Set("include[songs]", "artists")
params.Set("extend", "artistUrl")
searchURL := appleMusicCatalogBaseURL + "/search?" + params.Encode()
req, err := http.NewRequest("GET", searchURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create apple music catalog request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Origin", "https://music.apple.com")
req.Header.Set("Referer", "https://music.apple.com/")
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:95.0) Gecko/20100101 Firefox/95.0")
req.Header.Set("Accept", "application/json")
req.Header.Set("Accept-Language", "en-US,en;q=0.5")
req.Header.Set("x-apple-renewal", "true")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("apple music catalog search failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized {
return nil, fmt.Errorf("apple music catalog search unauthorized")
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("apple music catalog search returned HTTP %d", resp.StatusCode)
}
var searchResp appleMusicCatalogSearchResponse
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
return nil, fmt.Errorf("failed to decode apple music catalog response: %w", err)
}
if searchResp.Results.Songs == nil || searchResp.Resources == nil {
return nil, nil
}
results := make([]appleMusicSearchResult, 0, len(searchResp.Results.Songs.Data))
for _, item := range searchResp.Results.Songs.Data {
detail, ok := searchResp.Resources.Songs[item.ID]
if !ok {
continue
}
attr := detail.Attributes
results = append(results, appleMusicSearchResult{
ID: item.ID,
SongName: attr.Name,
ArtistName: attr.ArtistName,
AlbumName: attr.AlbumName,
Duration: attr.DurationInMillis,
})
}
return results, nil
}
func (c *AppleMusicClient) SearchSong(trackName, artistName string, durationSec float64) (string, error) {
query := trackName + " " + artistName
if strings.TrimSpace(query) == "" {
return "", fmt.Errorf("empty search query")
}
encodedQuery := url.QueryEscape(query)
searchURL := fmt.Sprintf("https://lyrics.paxsenix.org/apple-music/search?q=%s", encodedQuery)
req, err := http.NewRequest("GET", searchURL, nil)
token, err := c.getAppleMusicToken()
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
return "", err
}
req.Header.Set("User-Agent", appUserAgent())
req.Header.Set("Accept", "application/json")
resp, err := c.httpClient.Do(req)
searchResp, err := c.searchSongWithToken(token, strings.TrimSpace(query))
if err != nil && strings.Contains(strings.ToLower(err.Error()), "unauthorized") {
clearAppleMusicToken()
token, tokenErr := c.getAppleMusicToken()
if tokenErr != nil {
return "", tokenErr
}
searchResp, err = c.searchSongWithToken(token, strings.TrimSpace(query))
}
if err != nil {
return "", fmt.Errorf("apple music search failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("apple music search returned HTTP %d", resp.StatusCode)
}
var searchResp []appleMusicSearchResult
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
return "", fmt.Errorf("failed to decode apple music response: %w", err)
return "", err
}
best := selectBestAppleMusicSearchResult(searchResp, trackName, artistName, durationSec)
@@ -174,8 +335,33 @@ func (c *AppleMusicClient) FetchLyricsByID(songID string) (string, error) {
}
func formatPaxLyricsToLRC(rawJSON string, multiPersonWordByWord bool, preserveWordTiming bool) (string, error) {
var stringPayload string
if err := json.Unmarshal([]byte(rawJSON), &stringPayload); err == nil {
stringPayload = strings.TrimSpace(stringPayload)
if stringPayload != "" {
return stringPayload, nil
}
}
var paxResp paxResponse
if err := json.Unmarshal([]byte(rawJSON), &paxResp); err == nil && paxResp.Content != nil {
if err := json.Unmarshal([]byte(rawJSON), &paxResp); err == nil &&
(paxResp.Content != nil ||
strings.TrimSpace(paxResp.ELRCMultiPerson) != "" ||
strings.TrimSpace(paxResp.ELRC) != "" ||
strings.TrimSpace(paxResp.Plain) != "" ||
strings.TrimSpace(paxResp.TTMLContent) != "") {
if preserveWordTiming && multiPersonWordByWord && strings.TrimSpace(paxResp.ELRCMultiPerson) != "" {
return strings.TrimSpace(paxResp.ELRCMultiPerson), nil
}
if preserveWordTiming && strings.TrimSpace(paxResp.ELRC) != "" {
return strings.TrimSpace(paxResp.ELRC), nil
}
if strings.TrimSpace(paxResp.Plain) != "" && len(paxResp.Content) == 0 {
return strings.TrimSpace(paxResp.Plain), nil
}
if len(paxResp.Content) == 0 {
return "", fmt.Errorf("unsupported apple music lyrics payload")
}
return formatPaxContent(paxResp.Type, paxResp.Content, multiPersonWordByWord, preserveWordTiming), nil
}
@@ -270,6 +456,10 @@ func (c *AppleMusicClient) FetchLyrics(
lrcText, err := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord, preserveWordTiming)
if err != nil {
trimmedRaw := strings.TrimSpace(rawLyrics)
if strings.HasPrefix(trimmedRaw, "{") || strings.HasPrefix(trimmedRaw, "[") {
return nil, err
}
lrcText = rawLyrics
}
+12 -5
View File
@@ -131,14 +131,18 @@ func TestLyricsCacheParsingAndLRCLibClient(t *testing.T) {
}
func TestExternalLyricsProvidersWithFakeHTTP(t *testing.T) {
clearAppleMusicToken()
defer clearAppleMusicToken()
paxJSON := `{"type":"Syllable","content":[{"timestamp":1000,"oppositeTurn":true,"background":true,"text":[{"text":"Hel","part":true,"timestamp":1000},{"text":"lo","part":false,"timestamp":1200,"endtime":1500}],"backgroundText":[{"text":"bg","part":false,"timestamp":900}]}]}`
apple := &AppleMusicClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "/apple-music/search"):
if req.URL.Query().Get("q") == "bad" {
return &http.Response{StatusCode: 500, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`error`)), Request: req}, nil
}
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`[{"id":"apple-2","songName":"Other","artistName":"Other","duration":1000},{"id":"apple-1","songName":"Song","artistName":"Artist","albumName":"Album","duration":180000}]`)), Request: req}, nil
case req.URL.Host == "beta.music.apple.com" && (req.URL.Path == "" || req.URL.Path == "/"):
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`<script src="/assets/index~test.js"></script>`)), Request: req}, nil
case req.URL.Host == "beta.music.apple.com" && req.URL.Path == "/assets/index~test.js":
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`const token="eyJhbGci.test";`)), Request: req}, nil
case req.URL.Host == "amp-api.music.apple.com" && strings.Contains(req.URL.Path, "/v1/catalog/us/search"):
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"results":{"songs":{"data":[{"id":"apple-2"},{"id":"apple-1"}]}},"resources":{"songs":{"apple-2":{"attributes":{"name":"Other","artistName":"Other","durationInMillis":1000}},"apple-1":{"attributes":{"name":"Song","artistName":"Artist","albumName":"Album","durationInMillis":180000}}}}}`)), Request: req}, nil
case strings.Contains(req.URL.Path, "/apple-music/lyrics"):
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(paxJSON)), Request: req}, nil
default:
@@ -177,6 +181,9 @@ func TestExternalLyricsProvidersWithFakeHTTP(t *testing.T) {
if !strings.Contains(elrc, "<00:") {
t.Fatalf("elrc pax should include inline word timing: %q", elrc)
}
if preferred, err := formatPaxLyricsToLRC(`{"elrcMultiPerson":"[00:01.00]v1:<00:01.00>Hello","content":[{"timestamp":1000,"text":[{"text":"Fallback","part":false}]}]}`, true, true); err != nil || !strings.Contains(preferred, "Hello") {
t.Fatalf("preferred apple elrc = %q/%v", preferred, err)
}
if _, err := apple.SearchSong("", "", 0); err == nil {
t.Fatal("expected empty apple search error")
}
+82
View File
@@ -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,
}
}
+1 -1
View File
@@ -21,6 +21,6 @@
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>13.0</string>
<string>14.0</string>
</dict>
</plist>
+3 -3
View File
@@ -346,7 +346,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
@@ -472,7 +472,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
@@ -523,7 +523,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
+2 -2
View File
@@ -1,8 +1,8 @@
import 'package:flutter/foundation.dart';
class AppInfo {
static const String version = '4.5.5';
static const String buildNumber = '132';
static const String version = '4.5.6';
static const String buildNumber = '133';
static const String fullVersion = '$version+$buildNumber';
static String get displayVersion => kDebugMode ? 'Internal' : version;
+90
View File
@@ -1404,6 +1404,12 @@ abstract class AppLocalizations {
/// **'No tracks found'**
String get errorNoTracksFound;
/// Subtitle shown under the empty search result state on the home screen
///
/// In en, this message translates to:
/// **'Try another keyword'**
String get searchEmptyResultSubtitle;
/// Error title - URL not handled by any extension or service
///
/// In en, this message translates to:
@@ -6209,6 +6215,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:
@@ -6982,6 +7000,78 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Releases'**
String get artistReleases;
/// Button to clear selected fields for auto-fill
///
/// In en, this message translates to:
/// **'None'**
String get editMetadataSelectNone;
/// Button to retry every failed download in the queue
///
/// In en, this message translates to:
/// **'Retry {count} failed'**
String queueRetryAllFailed(int count);
/// Settings switch title for storing completed downloads in history
///
/// In en, this message translates to:
/// **'Save download history'**
String get settingsSaveDownloadHistory;
/// Settings switch subtitle for storing completed downloads in history
///
/// In en, this message translates to:
/// **'Keep completed downloads in history and library views'**
String get settingsSaveDownloadHistorySubtitle;
/// Confirmation dialog title shown before disabling download history
///
/// In en, this message translates to:
/// **'Turn off download history?'**
String get dialogDisableHistoryTitle;
/// Confirmation dialog message shown before disabling download history
///
/// In en, this message translates to:
/// **'Existing history will be cleared. Downloaded files will not be deleted.'**
String get dialogDisableHistoryMessage;
/// Confirmation action to disable download history and clear existing entries
///
/// In en, this message translates to:
/// **'Turn off and clear'**
String get dialogDisableAndClear;
/// Title and tooltip for finding the current collection in other services
///
/// In en, this message translates to:
/// **'Open in Other Services'**
String get openInOtherServices;
/// Empty state when no extensions can be searched for cross-service links
///
/// In en, this message translates to:
/// **'No other compatible services'**
String get shareSheetNoExtensions;
/// Cross-service share sheet row subtitle when a service has no match
///
/// In en, this message translates to:
/// **'Not found'**
String get shareSheetNotFound;
/// Tooltip for copying a cross-service link
///
/// In en, this message translates to:
/// **'Copy Link'**
String get shareSheetCopyLink;
/// Snackbar after copying a cross-service link
///
/// In en, this message translates to:
/// **'{service} link copied'**
String shareSheetLinkCopied(Object service);
}
class _AppLocalizationsDelegate
+52
View File
@@ -754,6 +754,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get errorNoTracksFound => 'Keine Titel gefunden';
@override
String get searchEmptyResultSubtitle => 'Try another keyword';
@override
String get errorUrlNotRecognized => 'Link wurde nicht erkannt';
@@ -3696,6 +3699,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';
@@ -4222,4 +4232,46 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get artistReleases => 'Releases';
@override
String get editMetadataSelectNone => 'None';
@override
String queueRetryAllFailed(int count) {
return 'Retry $count failed';
}
@override
String get settingsSaveDownloadHistory => 'Save download history';
@override
String get settingsSaveDownloadHistorySubtitle =>
'Keep completed downloads in history and library views';
@override
String get dialogDisableHistoryTitle => 'Turn off download history?';
@override
String get dialogDisableHistoryMessage =>
'Existing history will be cleared. Downloaded files will not be deleted.';
@override
String get dialogDisableAndClear => 'Turn off and clear';
@override
String get openInOtherServices => 'Open in Other Services';
@override
String get shareSheetNoExtensions => 'No other compatible services';
@override
String get shareSheetNotFound => 'Not found';
@override
String get shareSheetCopyLink => 'Copy Link';
@override
String shareSheetLinkCopied(Object service) {
return '$service link copied';
}
}
+52
View File
@@ -742,6 +742,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get errorNoTracksFound => 'No tracks found';
@override
String get searchEmptyResultSubtitle => 'Try another keyword';
@override
String get errorUrlNotRecognized => 'Link not recognized';
@@ -3667,6 +3670,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';
@@ -4193,4 +4203,46 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get artistReleases => 'Releases';
@override
String get editMetadataSelectNone => 'None';
@override
String queueRetryAllFailed(int count) {
return 'Retry $count failed';
}
@override
String get settingsSaveDownloadHistory => 'Save download history';
@override
String get settingsSaveDownloadHistorySubtitle =>
'Keep completed downloads in history and library views';
@override
String get dialogDisableHistoryTitle => 'Turn off download history?';
@override
String get dialogDisableHistoryMessage =>
'Existing history will be cleared. Downloaded files will not be deleted.';
@override
String get dialogDisableAndClear => 'Turn off and clear';
@override
String get openInOtherServices => 'Open in Other Services';
@override
String get shareSheetNoExtensions => 'No other compatible services';
@override
String get shareSheetNotFound => 'Not found';
@override
String get shareSheetCopyLink => 'Copy Link';
@override
String shareSheetLinkCopied(Object service) {
return '$service link copied';
}
}
+52
View File
@@ -742,6 +742,9 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get errorNoTracksFound => 'No tracks found';
@override
String get searchEmptyResultSubtitle => 'Try another keyword';
@override
String get errorUrlNotRecognized => 'Link not recognized';
@@ -3661,6 +3664,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';
@@ -4187,6 +4197,48 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get artistReleases => 'Releases';
@override
String get editMetadataSelectNone => 'None';
@override
String queueRetryAllFailed(int count) {
return 'Retry $count failed';
}
@override
String get settingsSaveDownloadHistory => 'Save download history';
@override
String get settingsSaveDownloadHistorySubtitle =>
'Keep completed downloads in history and library views';
@override
String get dialogDisableHistoryTitle => 'Turn off download history?';
@override
String get dialogDisableHistoryMessage =>
'Existing history will be cleared. Downloaded files will not be deleted.';
@override
String get dialogDisableAndClear => 'Turn off and clear';
@override
String get openInOtherServices => 'Open in Other Services';
@override
String get shareSheetNoExtensions => 'No other compatible services';
@override
String get shareSheetNotFound => 'Not found';
@override
String get shareSheetCopyLink => 'Copy Link';
@override
String shareSheetLinkCopied(Object service) {
return '$service link copied';
}
}
/// The translations for Spanish Castilian, as used in Spain (`es_ES`).
+52
View File
@@ -745,6 +745,9 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get errorNoTracksFound => 'No tracks found';
@override
String get searchEmptyResultSubtitle => 'Try another keyword';
@override
String get errorUrlNotRecognized => 'Link not recognized';
@@ -3665,6 +3668,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';
@@ -4191,4 +4201,46 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get artistReleases => 'Releases';
@override
String get editMetadataSelectNone => 'None';
@override
String queueRetryAllFailed(int count) {
return 'Retry $count failed';
}
@override
String get settingsSaveDownloadHistory => 'Save download history';
@override
String get settingsSaveDownloadHistorySubtitle =>
'Keep completed downloads in history and library views';
@override
String get dialogDisableHistoryTitle => 'Turn off download history?';
@override
String get dialogDisableHistoryMessage =>
'Existing history will be cleared. Downloaded files will not be deleted.';
@override
String get dialogDisableAndClear => 'Turn off and clear';
@override
String get openInOtherServices => 'Open in Other Services';
@override
String get shareSheetNoExtensions => 'No other compatible services';
@override
String get shareSheetNotFound => 'Not found';
@override
String get shareSheetCopyLink => 'Copy Link';
@override
String shareSheetLinkCopied(Object service) {
return '$service link copied';
}
}
+52
View File
@@ -742,6 +742,9 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get errorNoTracksFound => 'No tracks found';
@override
String get searchEmptyResultSubtitle => 'Try another keyword';
@override
String get errorUrlNotRecognized => 'Link not recognized';
@@ -3662,6 +3665,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';
@@ -4188,4 +4198,46 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get artistReleases => 'Releases';
@override
String get editMetadataSelectNone => 'None';
@override
String queueRetryAllFailed(int count) {
return 'Retry $count failed';
}
@override
String get settingsSaveDownloadHistory => 'Save download history';
@override
String get settingsSaveDownloadHistorySubtitle =>
'Keep completed downloads in history and library views';
@override
String get dialogDisableHistoryTitle => 'Turn off download history?';
@override
String get dialogDisableHistoryMessage =>
'Existing history will be cleared. Downloaded files will not be deleted.';
@override
String get dialogDisableAndClear => 'Turn off and clear';
@override
String get openInOtherServices => 'Open in Other Services';
@override
String get shareSheetNoExtensions => 'No other compatible services';
@override
String get shareSheetNotFound => 'Not found';
@override
String get shareSheetCopyLink => 'Copy Link';
@override
String shareSheetLinkCopied(Object service) {
return '$service link copied';
}
}
+52
View File
@@ -745,6 +745,9 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get errorNoTracksFound => 'Tidak ada lagu ditemukan';
@override
String get searchEmptyResultSubtitle => 'Coba kata kunci lain';
@override
String get errorUrlNotRecognized => 'Tautan tidak dikenali';
@@ -3653,6 +3656,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';
@@ -4179,4 +4189,46 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get artistReleases => 'Releases';
@override
String get editMetadataSelectNone => 'Tidak ada';
@override
String queueRetryAllFailed(int count) {
return 'Coba ulang $count gagal';
}
@override
String get settingsSaveDownloadHistory => 'Simpan riwayat unduhan';
@override
String get settingsSaveDownloadHistorySubtitle =>
'Simpan unduhan selesai di riwayat dan tampilan pustaka';
@override
String get dialogDisableHistoryTitle => 'Matikan riwayat unduhan?';
@override
String get dialogDisableHistoryMessage =>
'Riwayat yang ada akan dihapus. File unduhan tidak akan dihapus.';
@override
String get dialogDisableAndClear => 'Matikan dan hapus';
@override
String get openInOtherServices => 'Buka di Layanan Lain';
@override
String get shareSheetNoExtensions => 'Tidak ada layanan lain yang kompatibel';
@override
String get shareSheetNotFound => 'Tidak ditemukan';
@override
String get shareSheetCopyLink => 'Salin Tautan';
@override
String shareSheetLinkCopied(Object service) {
return 'Tautan $service disalin';
}
}
+52
View File
@@ -737,6 +737,9 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get errorNoTracksFound => 'トラックがありません';
@override
String get searchEmptyResultSubtitle => 'Try another keyword';
@override
String get errorUrlNotRecognized => 'Link not recognized';
@@ -3649,6 +3652,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';
@@ -4175,4 +4185,46 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get artistReleases => 'Releases';
@override
String get editMetadataSelectNone => 'None';
@override
String queueRetryAllFailed(int count) {
return 'Retry $count failed';
}
@override
String get settingsSaveDownloadHistory => 'Save download history';
@override
String get settingsSaveDownloadHistorySubtitle =>
'Keep completed downloads in history and library views';
@override
String get dialogDisableHistoryTitle => 'Turn off download history?';
@override
String get dialogDisableHistoryMessage =>
'Existing history will be cleared. Downloaded files will not be deleted.';
@override
String get dialogDisableAndClear => 'Turn off and clear';
@override
String get openInOtherServices => 'Open in Other Services';
@override
String get shareSheetNoExtensions => 'No other compatible services';
@override
String get shareSheetNotFound => 'Not found';
@override
String get shareSheetCopyLink => 'Copy Link';
@override
String shareSheetLinkCopied(Object service) {
return '$service link copied';
}
}
+52
View File
@@ -724,6 +724,9 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get errorNoTracksFound => '트랙을 찾을 수 없습니다';
@override
String get searchEmptyResultSubtitle => 'Try another keyword';
@override
String get errorUrlNotRecognized => 'Link not recognized';
@@ -3642,6 +3645,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';
@@ -4168,4 +4178,46 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get artistReleases => 'Releases';
@override
String get editMetadataSelectNone => 'None';
@override
String queueRetryAllFailed(int count) {
return 'Retry $count failed';
}
@override
String get settingsSaveDownloadHistory => 'Save download history';
@override
String get settingsSaveDownloadHistorySubtitle =>
'Keep completed downloads in history and library views';
@override
String get dialogDisableHistoryTitle => 'Turn off download history?';
@override
String get dialogDisableHistoryMessage =>
'Existing history will be cleared. Downloaded files will not be deleted.';
@override
String get dialogDisableAndClear => 'Turn off and clear';
@override
String get openInOtherServices => 'Open in Other Services';
@override
String get shareSheetNoExtensions => 'No other compatible services';
@override
String get shareSheetNotFound => 'Not found';
@override
String get shareSheetCopyLink => 'Copy Link';
@override
String shareSheetLinkCopied(Object service) {
return '$service link copied';
}
}
+52
View File
@@ -742,6 +742,9 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get errorNoTracksFound => 'No tracks found';
@override
String get searchEmptyResultSubtitle => 'Try another keyword';
@override
String get errorUrlNotRecognized => 'Link not recognized';
@@ -3662,6 +3665,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';
@@ -4188,4 +4198,46 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get artistReleases => 'Releases';
@override
String get editMetadataSelectNone => 'None';
@override
String queueRetryAllFailed(int count) {
return 'Retry $count failed';
}
@override
String get settingsSaveDownloadHistory => 'Save download history';
@override
String get settingsSaveDownloadHistorySubtitle =>
'Keep completed downloads in history and library views';
@override
String get dialogDisableHistoryTitle => 'Turn off download history?';
@override
String get dialogDisableHistoryMessage =>
'Existing history will be cleared. Downloaded files will not be deleted.';
@override
String get dialogDisableAndClear => 'Turn off and clear';
@override
String get openInOtherServices => 'Open in Other Services';
@override
String get shareSheetNoExtensions => 'No other compatible services';
@override
String get shareSheetNotFound => 'Not found';
@override
String get shareSheetCopyLink => 'Copy Link';
@override
String shareSheetLinkCopied(Object service) {
return '$service link copied';
}
}
+52
View File
@@ -742,6 +742,9 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get errorNoTracksFound => 'No tracks found';
@override
String get searchEmptyResultSubtitle => 'Try another keyword';
@override
String get errorUrlNotRecognized => 'Link not recognized';
@@ -3661,6 +3664,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';
@@ -4187,6 +4197,48 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get artistReleases => 'Releases';
@override
String get editMetadataSelectNone => 'None';
@override
String queueRetryAllFailed(int count) {
return 'Retry $count failed';
}
@override
String get settingsSaveDownloadHistory => 'Save download history';
@override
String get settingsSaveDownloadHistorySubtitle =>
'Keep completed downloads in history and library views';
@override
String get dialogDisableHistoryTitle => 'Turn off download history?';
@override
String get dialogDisableHistoryMessage =>
'Existing history will be cleared. Downloaded files will not be deleted.';
@override
String get dialogDisableAndClear => 'Turn off and clear';
@override
String get openInOtherServices => 'Open in Other Services';
@override
String get shareSheetNoExtensions => 'No other compatible services';
@override
String get shareSheetNotFound => 'Not found';
@override
String get shareSheetCopyLink => 'Copy Link';
@override
String shareSheetLinkCopied(Object service) {
return '$service link copied';
}
}
/// The translations for Portuguese, as used in Portugal (`pt_PT`).
+52
View File
@@ -755,6 +755,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get errorNoTracksFound => 'Треки не найдены';
@override
String get searchEmptyResultSubtitle => 'Try another keyword';
@override
String get errorUrlNotRecognized => 'Ссылка не распознана';
@@ -3721,6 +3724,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';
@@ -4247,4 +4257,46 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get artistReleases => 'Releases';
@override
String get editMetadataSelectNone => 'None';
@override
String queueRetryAllFailed(int count) {
return 'Retry $count failed';
}
@override
String get settingsSaveDownloadHistory => 'Save download history';
@override
String get settingsSaveDownloadHistorySubtitle =>
'Keep completed downloads in history and library views';
@override
String get dialogDisableHistoryTitle => 'Turn off download history?';
@override
String get dialogDisableHistoryMessage =>
'Existing history will be cleared. Downloaded files will not be deleted.';
@override
String get dialogDisableAndClear => 'Turn off and clear';
@override
String get openInOtherServices => 'Open in Other Services';
@override
String get shareSheetNoExtensions => 'No other compatible services';
@override
String get shareSheetNotFound => 'Not found';
@override
String get shareSheetCopyLink => 'Copy Link';
@override
String shareSheetLinkCopied(Object service) {
return '$service link copied';
}
}
+52
View File
@@ -750,6 +750,9 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get errorNoTracksFound => 'Parça bulunamadı';
@override
String get searchEmptyResultSubtitle => 'Try another keyword';
@override
String get errorUrlNotRecognized => 'Bağlantı tanınamadı';
@@ -3688,6 +3691,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';
@@ -4214,4 +4224,46 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get artistReleases => 'Releases';
@override
String get editMetadataSelectNone => 'None';
@override
String queueRetryAllFailed(int count) {
return 'Retry $count failed';
}
@override
String get settingsSaveDownloadHistory => 'Save download history';
@override
String get settingsSaveDownloadHistorySubtitle =>
'Keep completed downloads in history and library views';
@override
String get dialogDisableHistoryTitle => 'Turn off download history?';
@override
String get dialogDisableHistoryMessage =>
'Existing history will be cleared. Downloaded files will not be deleted.';
@override
String get dialogDisableAndClear => 'Turn off and clear';
@override
String get openInOtherServices => 'Open in Other Services';
@override
String get shareSheetNoExtensions => 'No other compatible services';
@override
String get shareSheetNotFound => 'Not found';
@override
String get shareSheetCopyLink => 'Copy Link';
@override
String shareSheetLinkCopied(Object service) {
return '$service link copied';
}
}
+52
View File
@@ -755,6 +755,9 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get errorNoTracksFound => 'Треків не знайдено';
@override
String get searchEmptyResultSubtitle => 'Try another keyword';
@override
String get errorUrlNotRecognized => 'Посилання не розпізнано';
@@ -3721,6 +3724,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';
@@ -4247,4 +4257,46 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get artistReleases => 'Releases';
@override
String get editMetadataSelectNone => 'None';
@override
String queueRetryAllFailed(int count) {
return 'Retry $count failed';
}
@override
String get settingsSaveDownloadHistory => 'Save download history';
@override
String get settingsSaveDownloadHistorySubtitle =>
'Keep completed downloads in history and library views';
@override
String get dialogDisableHistoryTitle => 'Turn off download history?';
@override
String get dialogDisableHistoryMessage =>
'Existing history will be cleared. Downloaded files will not be deleted.';
@override
String get dialogDisableAndClear => 'Turn off and clear';
@override
String get openInOtherServices => 'Open in Other Services';
@override
String get shareSheetNoExtensions => 'No other compatible services';
@override
String get shareSheetNotFound => 'Not found';
@override
String get shareSheetCopyLink => 'Copy Link';
@override
String shareSheetLinkCopied(Object service) {
return '$service link copied';
}
}
+52
View File
@@ -742,6 +742,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get errorNoTracksFound => 'No tracks found';
@override
String get searchEmptyResultSubtitle => 'Try another keyword';
@override
String get errorUrlNotRecognized => 'Link not recognized';
@@ -3661,6 +3664,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';
@@ -4187,6 +4197,48 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get artistReleases => 'Releases';
@override
String get editMetadataSelectNone => 'None';
@override
String queueRetryAllFailed(int count) {
return 'Retry $count failed';
}
@override
String get settingsSaveDownloadHistory => 'Save download history';
@override
String get settingsSaveDownloadHistorySubtitle =>
'Keep completed downloads in history and library views';
@override
String get dialogDisableHistoryTitle => 'Turn off download history?';
@override
String get dialogDisableHistoryMessage =>
'Existing history will be cleared. Downloaded files will not be deleted.';
@override
String get dialogDisableAndClear => 'Turn off and clear';
@override
String get openInOtherServices => 'Open in Other Services';
@override
String get shareSheetNoExtensions => 'No other compatible services';
@override
String get shareSheetNotFound => 'Not found';
@override
String get shareSheetCopyLink => 'Copy Link';
@override
String shareSheetLinkCopied(Object service) {
return '$service link copied';
}
}
/// The translations for Chinese, as used in China (`zh_CN`).
+68
View File
@@ -961,6 +961,10 @@
"@errorNoTracksFound": {
"description": "Error - search returned no results"
},
"searchEmptyResultSubtitle": "Try another keyword",
"@searchEmptyResultSubtitle": {
"description": "Subtitle shown under the empty search result state on the home screen"
},
"errorUrlNotRecognized": "Link not recognized",
"@errorUrlNotRecognized": {
"description": "Error title - URL not handled by any extension or service"
@@ -4808,6 +4812,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",
@@ -5486,5 +5498,61 @@
"artistReleases": "Releases",
"@artistReleases": {
"description": "Section header for all artist releases"
},
"editMetadataSelectNone": "None",
"@editMetadataSelectNone": {
"description": "Button to clear selected fields for auto-fill"
},
"queueRetryAllFailed": "Retry {count} failed",
"@queueRetryAllFailed": {
"description": "Button to retry every failed download in the queue",
"placeholders": {
"count": {
"type": "int"
}
}
},
"settingsSaveDownloadHistory": "Save download history",
"@settingsSaveDownloadHistory": {
"description": "Settings switch title for storing completed downloads in history"
},
"settingsSaveDownloadHistorySubtitle": "Keep completed downloads in history and library views",
"@settingsSaveDownloadHistorySubtitle": {
"description": "Settings switch subtitle for storing completed downloads in history"
},
"dialogDisableHistoryTitle": "Turn off download history?",
"@dialogDisableHistoryTitle": {
"description": "Confirmation dialog title shown before disabling download history"
},
"dialogDisableHistoryMessage": "Existing history will be cleared. Downloaded files will not be deleted.",
"@dialogDisableHistoryMessage": {
"description": "Confirmation dialog message shown before disabling download history"
},
"dialogDisableAndClear": "Turn off and clear",
"@dialogDisableAndClear": {
"description": "Confirmation action to disable download history and clear existing entries"
},
"openInOtherServices": "Open in Other Services",
"@openInOtherServices": {
"description": "Title and tooltip for finding the current collection in other services"
},
"shareSheetNoExtensions": "No other compatible services",
"@shareSheetNoExtensions": {
"description": "Empty state when no extensions can be searched for cross-service links"
},
"shareSheetNotFound": "Not found",
"@shareSheetNotFound": {
"description": "Cross-service share sheet row subtitle when a service has no match"
},
"shareSheetCopyLink": "Copy Link",
"@shareSheetCopyLink": {
"description": "Tooltip for copying a cross-service link"
},
"shareSheetLinkCopied": "{service} link copied",
"@shareSheetLinkCopied": {
"description": "Snackbar after copying a cross-service link",
"placeholders": {
"service": {}
}
}
}
+68
View File
@@ -905,6 +905,10 @@
"@errorNoTracksFound": {
"description": "Error - search returned no results"
},
"searchEmptyResultSubtitle": "Coba kata kunci lain",
"@searchEmptyResultSubtitle": {
"description": "Subtitle shown under the empty search result state on the home screen"
},
"errorUrlNotRecognized": "Tautan tidak dikenali",
"@errorUrlNotRecognized": {
"description": "Error title - URL not handled by any extension or service"
@@ -4614,5 +4618,69 @@
"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"
},
"editMetadataSelectNone": "Tidak ada",
"@editMetadataSelectNone": {
"description": "Button to clear selected fields for auto-fill"
},
"queueRetryAllFailed": "Coba ulang {count} gagal",
"@queueRetryAllFailed": {
"description": "Button to retry every failed download in the queue",
"placeholders": {
"count": {
"type": "int"
}
}
},
"settingsSaveDownloadHistory": "Simpan riwayat unduhan",
"@settingsSaveDownloadHistory": {
"description": "Settings switch title for storing completed downloads in history"
},
"settingsSaveDownloadHistorySubtitle": "Simpan unduhan selesai di riwayat dan tampilan pustaka",
"@settingsSaveDownloadHistorySubtitle": {
"description": "Settings switch subtitle for storing completed downloads in history"
},
"dialogDisableHistoryTitle": "Matikan riwayat unduhan?",
"@dialogDisableHistoryTitle": {
"description": "Confirmation dialog title shown before disabling download history"
},
"dialogDisableHistoryMessage": "Riwayat yang ada akan dihapus. File unduhan tidak akan dihapus.",
"@dialogDisableHistoryMessage": {
"description": "Confirmation dialog message shown before disabling download history"
},
"dialogDisableAndClear": "Matikan dan hapus",
"@dialogDisableAndClear": {
"description": "Confirmation action to disable download history and clear existing entries"
},
"openInOtherServices": "Buka di Layanan Lain",
"@openInOtherServices": {
"description": "Title and tooltip for finding the current collection in other services"
},
"shareSheetNoExtensions": "Tidak ada layanan lain yang kompatibel",
"@shareSheetNoExtensions": {
"description": "Empty state when no extensions can be searched for cross-service links"
},
"shareSheetNotFound": "Tidak ditemukan",
"@shareSheetNotFound": {
"description": "Cross-service share sheet row subtitle when a service has no match"
},
"shareSheetCopyLink": "Salin Tautan",
"@shareSheetCopyLink": {
"description": "Tooltip for copying a cross-service link"
},
"shareSheetLinkCopied": "Tautan {service} disalin",
"@shareSheetLinkCopied": {
"description": "Snackbar after copying a cross-service link",
"placeholders": {
"service": {}
}
}
}
+1 -1
View File
@@ -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:
+4
View File
@@ -91,6 +91,7 @@ class AppSettings {
final bool
deduplicateDownloads; // Skip downloading tracks already present in history
final bool saveDownloadHistory; // Record completed downloads in local history
const AppSettings({
this.defaultService = '',
@@ -152,6 +153,7 @@ class AppSettings {
this.musixmatchLanguage = '',
this.lastSeenVersion = '',
this.deduplicateDownloads = true,
this.saveDownloadHistory = true,
});
AppSettings copyWith({
@@ -217,6 +219,7 @@ class AppSettings {
String? musixmatchLanguage,
String? lastSeenVersion,
bool? deduplicateDownloads,
bool? saveDownloadHistory,
}) {
return AppSettings(
defaultService: defaultService ?? this.defaultService,
@@ -300,6 +303,7 @@ class AppSettings {
musixmatchLanguage: musixmatchLanguage ?? this.musixmatchLanguage,
lastSeenVersion: lastSeenVersion ?? this.lastSeenVersion,
deduplicateDownloads: deduplicateDownloads ?? this.deduplicateDownloads,
saveDownloadHistory: saveDownloadHistory ?? this.saveDownloadHistory,
);
}
+2
View File
@@ -82,6 +82,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
musixmatchLanguage: json['musixmatchLanguage'] as String? ?? '',
lastSeenVersion: json['lastSeenVersion'] as String? ?? '',
deduplicateDownloads: json['deduplicateDownloads'] as bool? ?? true,
saveDownloadHistory: json['saveDownloadHistory'] as bool? ?? true,
);
Map<String, dynamic> _$AppSettingsToJson(
@@ -147,4 +148,5 @@ Map<String, dynamic> _$AppSettingsToJson(
'musixmatchLanguage': instance.musixmatchLanguage,
'lastSeenVersion': instance.lastSeenVersion,
'deduplicateDownloads': instance.deduplicateDownloads,
'saveDownloadHistory': instance.saveDownloadHistory,
};
+191 -101
View File
@@ -4023,6 +4023,57 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
void retryAllFailed() {
final failedIds = state.items
.where(
(item) =>
item.status == DownloadStatus.failed ||
item.status == DownloadStatus.skipped,
)
.map((item) => item.id)
.toSet();
if (failedIds.isEmpty) {
_log.d('retryAllFailed: no failed downloads to retry');
return;
}
_log.i('Retrying ${failedIds.length} failed download(s)');
_locallyCancelledItemIds.removeAll(failedIds);
_pausePendingItemIds.removeAll(failedIds);
for (final item in state.items) {
if (!failedIds.contains(item.id)) continue;
final rgKey = _albumRgKey(item.track);
final rgAcc = _albumRgData[rgKey];
if (rgAcc == null) continue;
rgAcc.entries.removeWhere((entry) => entry.trackId == item.track.id);
if (rgAcc.entries.isEmpty) {
_albumRgData.remove(rgKey);
}
}
final items = state.items
.map((item) {
if (!failedIds.contains(item.id)) return item;
return item.copyWith(
status: DownloadStatus.queued,
progress: 0,
speedMBps: 0,
bytesReceived: 0,
bytesTotal: 0,
error: null,
);
})
.toList(growable: false);
state = state.copyWith(items: items, isPaused: false);
_saveQueueToStorage();
if (!state.isProcessing) {
Future.microtask(() => _processQueue());
}
}
void removeItem(String id) {
final removedItem = state.items.where((item) => item.id == id).firstOrNull;
_locallyCancelledItemIds.remove(id);
@@ -5336,6 +5387,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
DownloadRequestPayload.nativeWorkerContractVersion,
'run_id': runId,
'created_at': DateTime.now().toIso8601String(),
'save_download_history': settings.saveDownloadHistory,
},
);
@@ -5769,22 +5821,26 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
progress: 1.0,
filePath: filePath,
);
final historyItem = result['history_item'];
if (historyItem is Map) {
try {
ref
.read(downloadHistoryProvider.notifier)
.adoptNativeHistoryItem(
DownloadHistoryItem.fromJson(
Map<String, dynamic>.from(historyItem),
),
);
} catch (e) {
_log.w('Failed to adopt native history item: $e');
if (settings.saveDownloadHistory) {
final historyItem = result['history_item'];
if (historyItem is Map) {
try {
ref
.read(downloadHistoryProvider.notifier)
.adoptNativeHistoryItem(
DownloadHistoryItem.fromJson(
Map<String, dynamic>.from(historyItem),
),
);
} catch (e) {
_log.w('Failed to adopt native history item: $e');
await ref
.read(downloadHistoryProvider.notifier)
.reloadFromStorage();
}
} else if (result['history_written'] == true) {
await ref.read(downloadHistoryProvider.notifier).reloadFromStorage();
}
} else if (result['history_written'] == true) {
await ref.read(downloadHistoryProvider.notifier).reloadFromStorage();
}
_completedInSession++;
await _notificationService.showDownloadComplete(
@@ -5989,51 +6045,53 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
backendComposer,
);
ref
.read(downloadHistoryProvider.notifier)
.addToHistory(
DownloadHistoryItem(
id: item.id,
trackName: historyTitle,
artistName: historyArtist,
albumName: historyAlbum,
albumArtist: normalizeOptionalString(trackToDownload.albumArtist),
coverUrl: normalizeCoverReference(trackToDownload.coverUrl),
filePath: filePath,
storageMode: context.storageMode,
downloadTreeUri: context.storageMode == 'saf'
? context.downloadTreeUri
: null,
safRelativeDir: context.storageMode == 'saf'
? context.safRelativeDir
: null,
safFileName: context.storageMode == 'saf'
? ((resultSafFileName != null && resultSafFileName.isNotEmpty)
? resultSafFileName
: context.safFileName)
: null,
safRepaired: false,
service: result['service'] as String? ?? item.service,
downloadedAt: DateTime.now(),
isrc: historyIsrc,
spotifyId: trackToDownload.id,
trackNumber: historyTrackNumber,
totalTracks: historyTotalTracks,
discNumber: historyDiscNumber,
totalDiscs: historyTotalDiscs,
duration: trackToDownload.duration,
releaseDate: historyReleaseDate,
quality: actualQuality,
bitDepth: isLossyOutput ? null : actualBitDepth,
sampleRate: isLossyOutput ? null : actualSampleRate,
bitrate: isLossyOutput ? actualBitrate : null,
format: historyFormat,
genre: normalizeOptionalString(backendGenre),
composer: historyComposer,
label: normalizeOptionalString(backendLabel),
copyright: normalizeOptionalString(backendCopyright),
),
);
if (settings.saveDownloadHistory) {
ref
.read(downloadHistoryProvider.notifier)
.addToHistory(
DownloadHistoryItem(
id: item.id,
trackName: historyTitle,
artistName: historyArtist,
albumName: historyAlbum,
albumArtist: normalizeOptionalString(trackToDownload.albumArtist),
coverUrl: normalizeCoverReference(trackToDownload.coverUrl),
filePath: filePath,
storageMode: context.storageMode,
downloadTreeUri: context.storageMode == 'saf'
? context.downloadTreeUri
: null,
safRelativeDir: context.storageMode == 'saf'
? context.safRelativeDir
: null,
safFileName: context.storageMode == 'saf'
? ((resultSafFileName != null && resultSafFileName.isNotEmpty)
? resultSafFileName
: context.safFileName)
: null,
safRepaired: false,
service: result['service'] as String? ?? item.service,
downloadedAt: DateTime.now(),
isrc: historyIsrc,
spotifyId: trackToDownload.id,
trackNumber: historyTrackNumber,
totalTracks: historyTotalTracks,
discNumber: historyDiscNumber,
totalDiscs: historyTotalDiscs,
duration: trackToDownload.duration,
releaseDate: historyReleaseDate,
quality: actualQuality,
bitDepth: isLossyOutput ? null : actualBitDepth,
sampleRate: isLossyOutput ? null : actualSampleRate,
bitrate: isLossyOutput ? actualBitrate : null,
format: historyFormat,
genre: normalizeOptionalString(backendGenre),
composer: historyComposer,
label: normalizeOptionalString(backendLabel),
copyright: normalizeOptionalString(backendCopyright),
),
);
}
removeItem(item.id);
}
@@ -6551,6 +6609,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;
@@ -8633,47 +8717,51 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
backendComposer,
);
ref
.read(downloadHistoryProvider.notifier)
.addToHistory(
DownloadHistoryItem(
id: item.id,
trackName: historyTitle,
artistName: historyArtist,
albumName: historyAlbum,
albumArtist: historyAlbumArtist,
coverUrl: normalizeCoverReference(trackToDownload.coverUrl),
filePath: filePath,
storageMode: effectiveSafMode ? 'saf' : 'app',
downloadTreeUri: effectiveSafMode
? settings.downloadTreeUri
: null,
safRelativeDir: effectiveSafMode ? effectiveOutputDir : null,
safFileName: effectiveSafMode
? (finalSafFileName ?? safFileName)
: null,
safRepaired: false,
service: result['service'] as String? ?? item.service,
downloadedAt: DateTime.now(),
isrc: historyIsrc,
spotifyId: trackToDownload.id,
trackNumber: historyTrackNumber,
totalTracks: historyTotalTracks,
discNumber: historyDiscNumber,
totalDiscs: historyTotalDiscs,
duration: trackToDownload.duration,
releaseDate: historyReleaseDate,
quality: actualQuality,
bitDepth: historyBitDepth,
sampleRate: historySampleRate,
bitrate: historyBitrate,
format: finalFormat,
genre: effectiveGenre,
composer: historyComposer,
label: effectiveLabel,
copyright: effectiveCopyright,
),
);
if (settings.saveDownloadHistory) {
ref
.read(downloadHistoryProvider.notifier)
.addToHistory(
DownloadHistoryItem(
id: item.id,
trackName: historyTitle,
artistName: historyArtist,
albumName: historyAlbum,
albumArtist: historyAlbumArtist,
coverUrl: normalizeCoverReference(trackToDownload.coverUrl),
filePath: filePath,
storageMode: effectiveSafMode ? 'saf' : 'app',
downloadTreeUri: effectiveSafMode
? settings.downloadTreeUri
: null,
safRelativeDir: effectiveSafMode
? effectiveOutputDir
: null,
safFileName: effectiveSafMode
? (finalSafFileName ?? safFileName)
: null,
safRepaired: false,
service: result['service'] as String? ?? item.service,
downloadedAt: DateTime.now(),
isrc: historyIsrc,
spotifyId: trackToDownload.id,
trackNumber: historyTrackNumber,
totalTracks: historyTotalTracks,
discNumber: historyDiscNumber,
totalDiscs: historyTotalDiscs,
duration: trackToDownload.duration,
releaseDate: historyReleaseDate,
quality: actualQuality,
bitDepth: historyBitDepth,
sampleRate: historySampleRate,
bitrate: historyBitrate,
format: finalFormat,
genre: effectiveGenre,
composer: historyComposer,
label: effectiveLabel,
copyright: effectiveCopyright,
),
);
}
removeItem(item.id);
}
@@ -8723,7 +8811,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 +8865,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(
+5
View File
@@ -600,6 +600,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
state = state.copyWith(deduplicateDownloads: enabled);
_saveSettings();
}
void setSaveDownloadHistory(bool enabled) {
state = state.copyWith(saveDownloadHistory: enabled);
_saveSettings();
}
}
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
+57 -12
View File
@@ -21,6 +21,7 @@ import 'package:spotiflac_android/providers/library_collections_provider.dart';
import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart';
import 'package:spotiflac_android/utils/clickable_metadata.dart';
import 'package:spotiflac_android/widgets/audio_quality_badges.dart';
import 'package:spotiflac_android/widgets/cross_extension_share_sheet.dart';
class _AlbumCache {
static final Map<String, _CacheEntry> _cache = {};
@@ -566,18 +567,24 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
children: [
_buildLoveAllButton(),
const SizedBox(width: 12),
FilledButton.icon(
onPressed: () => _downloadAll(context),
icon: Icon(Icons.download, size: 18),
label: Text(
context.l10n.downloadAllCount(tracks.length),
),
style: FilledButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Colors.black87,
minimumSize: const Size(0, 48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
Flexible(
child: FilledButton.icon(
onPressed: () => _downloadAll(context),
icon: Icon(Icons.download, size: 18),
label: Text(
context.l10n.downloadAllCount(
tracks.length,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
style: FilledButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Colors.black87,
minimumSize: const Size(0, 48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
),
),
),
@@ -608,6 +615,23 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
),
onPressed: () => Navigator.pop(context),
),
actions: [
Padding(
padding: const EdgeInsets.only(right: 8),
child: IconButton(
tooltip: context.l10n.openInOtherServices,
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.4),
shape: BoxShape.circle,
),
child: const Icon(Icons.open_in_new_rounded, color: Colors.white),
),
onPressed: () => _showShareSheet(context, tracks, artistName),
),
),
],
);
}
@@ -846,6 +870,27 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
);
}
void _showShareSheet(
BuildContext context,
List<Track> tracks,
String? artistName,
) {
final sourceExtensionId = _directMetadataProviderId() ?? '';
final resolvedArtists =
artistName ??
tracks.firstOrNull?.albumArtist ??
tracks.firstOrNull?.artistName ??
'';
CrossExtensionShareSheet.show(
context,
name: widget.albumName,
artists: resolvedArtists,
type: 'album',
sourceExtensionId: sourceExtensionId,
);
}
Future<void> _loveAll(List<Track> tracks) async {
final notifier = ref.read(libraryCollectionsProvider.notifier);
final state = ref.read(libraryCollectionsProvider);
+28
View File
@@ -23,6 +23,7 @@ import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
import 'package:spotiflac_android/utils/clickable_metadata.dart';
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
import 'package:spotiflac_android/widgets/cross_extension_share_sheet.dart';
class _ArtistCache {
static final Map<String, _CacheEntry> _cache = {};
@@ -1333,6 +1334,33 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
),
onPressed: () => Navigator.pop(context),
),
actions: [
Padding(
padding: const EdgeInsets.only(right: 8),
child: IconButton(
tooltip: context.l10n.openInOtherServices,
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.4),
shape: BoxShape.circle,
),
child: const Icon(Icons.open_in_new_rounded, color: Colors.white),
),
onPressed: () => _showShareSheet(context),
),
),
],
);
}
void _showShareSheet(BuildContext context) {
CrossExtensionShareSheet.show(
context,
name: widget.artistName,
artists: '',
type: 'artist',
sourceExtensionId: _directMetadataProviderId() ?? '',
);
}
+116 -10
View File
@@ -46,6 +46,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
final _urlController = TextEditingController();
final FocusNode _searchFocusNode = FocusNode();
String? _lastSearchQuery;
String? _activeSearchInput;
bool _isResettingSearchSurface = false;
late final ProviderSubscription<TrackState> _trackStateSub;
late final ProviderSubscription<bool> _extensionInitSub;
late final ProviderSubscription<bool> _homeFeedExtSub;
@@ -557,9 +559,18 @@ class _HomeTabState extends ConsumerState<HomeTab>
if (text.isEmpty) {
_liveSearchDebounce?.cancel();
_activeSearchInput = null;
_lastSearchQuery = null;
if (!_isResettingSearchSurface) {
_resetSearchSurface(clearText: false);
}
return;
}
if (_activeSearchInput != null && _activeSearchInput != text) {
_activeSearchInput = null;
}
if (_isLiveSearchEnabled() && text.length >= _minLiveSearchChars) {
if (text.startsWith('http') || text.startsWith('spotify:')) return;
@@ -638,10 +649,17 @@ class _HomeTabState extends ConsumerState<HomeTab>
final searchKey =
'${searchProvider ?? 'default'}:$query:${selectedFilter ?? 'all'}';
if (_lastSearchQuery == searchKey) return;
if (_lastSearchQuery == searchKey) {
_activeSearchInput = query;
ref.read(trackProvider.notifier).setSearchText(query.trim().isNotEmpty);
if (mounted) setState(() {});
return;
}
_lastSearchQuery = searchKey;
_activeSearchInput = query;
_searchSortOption = _SearchSortOption.defaultOrder;
_invalidateSearchSortCaches();
ref.read(trackProvider.notifier).setSearchText(query.trim().isNotEmpty);
final isExtensionEnabled =
searchProvider != null &&
@@ -686,12 +704,26 @@ class _HomeTabState extends ConsumerState<HomeTab>
}
Future<void> _clearAndRefresh() async {
_liveSearchDebounce?.cancel();
_pendingLiveSearchQuery = null;
_urlController.clear();
_searchFocusNode.unfocus();
_lastSearchQuery = null;
ref.read(trackProvider.notifier).clear();
_resetSearchSurface();
}
void _resetSearchSurface({bool clearText = true}) {
if (_isResettingSearchSurface) return;
_isResettingSearchSurface = true;
try {
_liveSearchDebounce?.cancel();
_pendingLiveSearchQuery = null;
_lastSearchQuery = null;
_activeSearchInput = null;
FocusManager.instance.primaryFocus?.unfocus();
if (clearText && _urlController.text.isNotEmpty) {
_urlController.clear();
}
ref.read(trackProvider.notifier).clear();
if (mounted) setState(() {});
} finally {
_isResettingSearchSurface = false;
}
}
Future<void> _fetchMetadata() async {
@@ -1114,6 +1146,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
),
);
final isLoading = ref.watch(trackProvider.select((s) => s.isLoading));
final searchError = ref.watch(trackProvider.select((s) => s.error));
final hasSearchedBefore = ref.watch(
settingsProvider.select((s) => s.hasSearchedBefore),
);
@@ -1153,6 +1186,18 @@ class _HomeTabState extends ConsumerState<HomeTab>
final isSearchFocused = _searchFocusNode.hasFocus;
final hasShortSearchInput =
hasSearchInput && searchText.length < _minLiveSearchChars;
final hasSearchError = hasSearchInput && searchError != null;
final hasActiveSearchSurface =
hasSearchInput &&
(_activeSearchInput == searchText ||
hasActualResults ||
isLoading ||
hasSearchError);
final showEmptySearchResult =
hasActiveSearchSurface &&
!hasActualResults &&
!isLoading &&
searchError == null;
final isShowingRecentAccess = ref.watch(
trackProvider.select((s) => s.isShowingRecentAccess),
);
@@ -1166,7 +1211,11 @@ class _HomeTabState extends ConsumerState<HomeTab>
final recentModeRequested = isShowingRecentAccess || isSearchFocused;
final showRecentAccess =
recentModeRequested &&
(!hasSearchInput || hasShortSearchInput || !hasActualResults) &&
(!hasSearchInput ||
hasShortSearchInput ||
(!hasActualResults &&
!hasSearchError &&
!hasActiveSearchSurface)) &&
!isLoading;
final isSearchProviderLoading =
!extensionReadiness.isInitialized && extensionReadiness.error == null;
@@ -1180,6 +1229,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
final showExplore =
!hasActualResults &&
!isLoading &&
!hasActiveSearchSurface &&
!showRecentAccess &&
!homeFeedDisabled &&
(hasHomeFeedExtension || hasExploreContent) &&
@@ -1299,7 +1349,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
),
),
if (hasActualResults && !showRecentAccess)
if (hasActiveSearchSurface &&
!showRecentAccess &&
!showEmptySearchResult)
Consumer(
builder: (context, ref, _) {
final currentSearchProvider = ref.watch(
@@ -1466,7 +1518,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
(searchAlbums != null && searchAlbums.isNotEmpty) ||
(searchPlaylists != null && searchPlaylists.isNotEmpty) ||
isLoading ||
error != null;
error != null ||
hasActiveSearchSurface;
return SliverMainAxisGroup(
slivers: _buildSearchResults(
@@ -1478,6 +1531,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
error: error,
colorScheme: colorScheme,
hasResults: hasResults,
showEmptySearchResult: showEmptySearchResult,
searchExtensionId: searchExtensionId,
showLocalLibraryIndicator: showLocalLibraryIndicator,
thumbnailSizesByExtensionId: thumbnailSizesByExtensionId,
@@ -2611,6 +2665,47 @@ class _HomeTabState extends ConsumerState<HomeTab>
);
}
Widget _buildEmptySearchResultWidget(ColorScheme colorScheme) {
return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 340),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 86,
height: 86,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colorScheme.surfaceContainerHighest,
),
child: Icon(
Icons.manage_search,
size: 46,
color: colorScheme.primary,
),
),
const SizedBox(height: 16),
Text(
context.l10n.errorNoTracksFound,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Text(
context.l10n.searchEmptyResultSubtitle,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
),
),
],
),
);
}
String _sortOptionLabel(_SearchSortOption option) {
switch (option) {
case _SearchSortOption.defaultOrder:
@@ -2888,6 +2983,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
required String? error,
required ColorScheme colorScheme,
required bool hasResults,
required bool showEmptySearchResult,
required String? searchExtensionId,
required bool showLocalLibraryIndicator,
required Map<String, (double, double)> thumbnailSizesByExtensionId,
@@ -2934,6 +3030,16 @@ class _HomeTabState extends ConsumerState<HomeTab>
child: LinearProgressIndicator(),
),
),
if (showEmptySearchResult && !hasActualData)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 96),
child: _buildEmptySearchResultWidget(colorScheme),
),
),
),
];
bool sortButtonShown = false;
+3 -6
View File
@@ -358,13 +358,10 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
WidgetRef ref,
String playlistId,
) async {
final result = await FilePicker.pickFiles(
type: FileType.image,
allowMultiple: false,
);
if (result == null || result.files.isEmpty) return;
final picked = await FilePicker.pickFile(type: FileType.image);
if (picked == null) return;
final path = result.files.first.path;
final path = picked.path;
if (path == null || path.isEmpty) return;
await ref
@@ -568,13 +568,10 @@ class _LibraryTracksFolderScreenState
final playlistId = widget.playlistId;
if (playlistId == null) return;
final result = await FilePicker.pickFiles(
type: FileType.image,
allowMultiple: false,
);
if (result == null || result.files.isEmpty) return;
final picked = await FilePicker.pickFile(type: FileType.image);
if (picked == null) return;
final path = result.files.first.path;
final path = picked.path;
if (path == null || path.isEmpty) return;
await ref
+19 -2
View File
@@ -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;
}
@@ -883,12 +893,14 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
List<String>? updateFields,
}) async {
final durationMs = (item.duration ?? 0) * 1000;
final artistTagMode = ref.read(settingsProvider).artistTagMode;
final settings = ref.read(settingsProvider);
final artistTagMode = settings.artistTagMode;
final request = <String, dynamic>{
'file_path': item.filePath,
'cover_url': '',
'max_quality': true,
'embed_lyrics': true,
'embed_lyrics': settings.embedLyrics,
'lyrics_mode': settings.lyricsMode,
'artist_tag_mode': artistTagMode,
'spotify_id': '',
'track_name': item.trackName,
@@ -911,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') {
+168 -18
View File
@@ -3018,24 +3018,77 @@ class _QueueTabState extends ConsumerState<QueueTab> {
final queueCount = ref.watch(
downloadQueueLookupProvider.select((lookup) => lookup.itemIds.length),
);
final failedCount = ref.watch(
downloadQueueProvider.select((state) => state.failedCount),
);
final isProcessing = ref.watch(
downloadQueueProvider.select((state) => state.isProcessing),
);
if (queueCount == 0) {
return const SliverToBoxAdapter(child: SizedBox.shrink());
}
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: Row(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
context.l10n.queueDownloadingCount(queueCount),
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
LayoutBuilder(
builder: (context, constraints) {
final title = Text(
context.l10n.queueDownloadingCount(queueCount),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
);
final actions = Wrap(
spacing: 4,
runSpacing: 4,
alignment: WrapAlignment.end,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
_buildPauseResumeButton(context, ref, colorScheme),
_buildClearAllButton(context, ref, colorScheme),
],
);
if (constraints.maxWidth < 420) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
title,
const SizedBox(height: 4),
Align(
alignment: Alignment.centerRight,
child: actions,
),
],
);
}
return Row(
children: [
Expanded(child: title),
const SizedBox(width: 8),
Flexible(child: actions),
],
);
},
),
const Spacer(),
_buildPauseResumeButton(context, ref, colorScheme),
const SizedBox(width: 4),
_buildClearAllButton(context, ref, colorScheme),
if (failedCount > 0 && !isProcessing) ...[
const SizedBox(height: 6),
Align(
alignment: Alignment.centerLeft,
child: _buildRetryAllFailedButton(
context,
ref,
colorScheme,
failedCount,
),
),
],
],
),
),
@@ -4007,6 +4060,24 @@ class _QueueTabState extends ConsumerState<QueueTab> {
);
}
Widget _buildRetryAllFailedButton(
BuildContext context,
WidgetRef ref,
ColorScheme colorScheme,
int failedCount,
) {
return TextButton.icon(
onPressed: () =>
ref.read(downloadQueueProvider.notifier).retryAllFailed(),
icon: const Icon(Icons.replay_rounded, size: 18),
label: Text(context.l10n.queueRetryAllFailed(failedCount)),
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
foregroundColor: colorScheme.primary,
),
);
}
Future<void> _showClearAllDialog(
BuildContext context,
WidgetRef ref,
@@ -4388,6 +4459,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
await _safeDeleteTempFile(tempPath!);
return false;
}
await writeReEnrichSafSidecarLrc(safUri: safUri, reEnrichResult: result);
}
if (_hasTextValue(downloadedCoverPath)) {
@@ -4400,6 +4472,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;
}
@@ -4408,12 +4489,14 @@ class _QueueTabState extends ConsumerState<QueueTab> {
List<String>? updateFields,
}) async {
final durationMs = (item.duration ?? 0) * 1000;
final artistTagMode = ref.read(settingsProvider).artistTagMode;
final settings = ref.read(settingsProvider);
final artistTagMode = settings.artistTagMode;
final request = <String, dynamic>{
'file_path': item.filePath,
'cover_url': '',
'max_quality': true,
'embed_lyrics': true,
'embed_lyrics': settings.embedLyrics,
'lyrics_mode': settings.lyricsMode,
'artist_tag_mode': artistTagMode,
'spotify_id': '',
'track_name': item.trackName,
@@ -4436,6 +4519,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') {
@@ -5663,12 +5751,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,
),
],
],
@@ -5685,6 +5771,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();
@@ -114,6 +114,33 @@ class AppSettingsPage extends ConsumerWidget {
subtitle: context.l10n.optionsClearHistorySubtitle,
onTap: () =>
_showClearHistoryDialog(context, ref, colorScheme),
),
SettingsSwitchItem(
icon: Icons.history_toggle_off_outlined,
title: context.l10n.settingsSaveDownloadHistory,
subtitle: context.l10n.settingsSaveDownloadHistorySubtitle,
value: settings.saveDownloadHistory,
onChanged: (enabled) {
if (enabled) {
ref
.read(settingsProvider.notifier)
.setSaveDownloadHistory(true);
return;
}
final hasHistory = ref
.read(downloadHistoryProvider)
.items
.isNotEmpty;
if (hasHistory) {
_showDisableHistoryDialog(context, ref, colorScheme);
return;
}
ref
.read(settingsProvider.notifier)
.setSaveDownloadHistory(false);
},
showDivider: false,
),
],
@@ -148,6 +175,40 @@ class AppSettingsPage extends ConsumerWidget {
);
}
void _showDisableHistoryDialog(
BuildContext context,
WidgetRef ref,
ColorScheme colorScheme,
) {
showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.dialogDisableHistoryTitle),
content: Text(context.l10n.dialogDisableHistoryMessage),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(context.l10n.dialogCancel),
),
TextButton(
onPressed: () {
ref.read(downloadHistoryProvider.notifier).clearHistory();
ref.read(settingsProvider.notifier).setSaveDownloadHistory(false);
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarHistoryCleared)),
);
},
child: Text(
context.l10n.dialogDisableAndClear,
style: TextStyle(color: colorScheme.error),
),
),
],
),
);
}
void _showClearHistoryDialog(
BuildContext context,
WidgetRef ref,
+1 -4
View File
@@ -286,10 +286,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
}
Future<void> _installExtension() async {
final result = await FilePicker.pickFiles(
type: FileType.any,
allowMultiple: true,
);
final result = await FilePicker.pickFiles(type: FileType.any);
if (result != null && result.files.isNotEmpty) {
final selectedPaths = result.files
@@ -91,9 +91,8 @@ class _LyricsProviderPriorityPageState
onToggle: () => _disableProvider(id),
);
},
onReorder: (oldIndex, newIndex) {
onReorderItem: (oldIndex, newIndex) {
setState(() {
if (newIndex > oldIndex) newIndex -= 1;
final item = _enabledProviders.removeAt(oldIndex);
_enabledProviders.insert(newIndex, item);
});
@@ -73,11 +73,8 @@ class _MetadataProviderPriorityPageState
.firstOrNull,
);
},
onReorder: (oldIndex, newIndex) {
onReorderItem: (oldIndex, newIndex) {
setState(() {
if (newIndex > oldIndex) {
newIndex -= 1;
}
final item = _providers.removeAt(oldIndex);
_providers.insert(newIndex, item);
_hasChanges = true;
@@ -145,11 +145,8 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
.firstOrNull,
);
},
onReorder: (oldIndex, newIndex) {
onReorderItem: (oldIndex, newIndex) {
setState(() {
if (newIndex > oldIndex) {
newIndex -= 1;
}
final item = _providers.removeAt(oldIndex);
_providers.insert(newIndex, item);
_hasChanges = true;
+90 -11
View File
@@ -201,30 +201,30 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
Future<void> _pickCoverImage() async {
try {
final result = await FilePicker.pickFiles(
type: FileType.image,
allowMultiple: false,
withData: true,
);
if (result == null || result.files.isEmpty) return;
final picked = await FilePicker.pickFile(type: FileType.image);
if (picked == null) return;
final picked = result.files.first;
final bytes = picked.bytes;
final sourcePath = picked.path;
Uint8List? bytes;
final needsByteFallback =
!_hasValue(sourcePath) && !_hasValue(picked.extension);
if (needsByteFallback) {
bytes = await picked.readAsBytes();
}
final extension = _resolveImageExtension(picked.extension, bytes);
final tempDir = await Directory.systemTemp.createTemp('edit_cover_');
final tempPath =
'${tempDir.path}${Platform.pathSeparator}cover.$extension';
if (bytes != null && bytes.isNotEmpty) {
await File(tempPath).writeAsBytes(bytes, flush: true);
} else if (sourcePath != null && sourcePath.isNotEmpty) {
if (sourcePath != null && sourcePath.isNotEmpty) {
final sourceFile = File(sourcePath);
if (!await sourceFile.exists()) {
throw Exception('Selected image is not accessible');
}
await sourceFile.copy(tempPath);
} else if (bytes != null && bytes.isNotEmpty) {
await File(tempPath).writeAsBytes(bytes, flush: true);
} else {
throw Exception('Unable to read selected image');
}
@@ -350,6 +350,10 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
});
}
void _selectNoFields() {
setState(_autoFillFields.clear);
}
String _normalizeMetadataText(String value) {
final collapsed = value
.toLowerCase()
@@ -599,6 +603,57 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
return score;
}
bool _metadataTextMatches(String current, String candidate) {
if (current.isEmpty || candidate.isEmpty) return false;
return current == candidate ||
candidate.contains(current) ||
current.contains(candidate);
}
bool _metadataMatchIsConfident(
Map<String, dynamic> track, {
required String currentTitle,
required String currentArtist,
required String currentAlbum,
required String currentIsrc,
}) {
final candidateIsrc = (track['isrc']?.toString() ?? '')
.trim()
.toUpperCase();
if (currentIsrc.isNotEmpty && candidateIsrc == currentIsrc) {
return true;
}
final candidateTitle = _normalizeMetadataText(
(track['name'] ?? track['title'] ?? '').toString(),
);
final candidateArtist = _normalizeMetadataText(
(track['artists'] ?? track['artist'] ?? '').toString(),
);
final candidateAlbum = _normalizeMetadataText(
(track['album_name'] ?? track['album'] ?? '').toString(),
);
final titleMatches = _metadataTextMatches(currentTitle, candidateTitle);
final artistMatches = _metadataTextMatches(currentArtist, candidateArtist);
final albumMatches = _metadataTextMatches(currentAlbum, candidateAlbum);
if (currentTitle.isNotEmpty && currentArtist.isNotEmpty) {
return titleMatches && artistMatches;
}
if (currentTitle.isNotEmpty && currentAlbum.isNotEmpty) {
return titleMatches && albumMatches;
}
if (currentTitle.isNotEmpty) {
return titleMatches;
}
if (currentAlbum.isNotEmpty) {
return albumMatches;
}
return false;
}
Future<void> _fetchAndFill() async {
if (_autoFillFields.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -683,6 +738,24 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
best = result;
}
}
if (best != null &&
!_metadataMatchIsConfident(
best,
currentTitle: normalizedTitle,
currentArtist: normalizedArtist,
currentAlbum: normalizedAlbum,
currentIsrc: currentIsrc,
)) {
best = null;
}
if (best == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.editMetadataAutoFillNoResults)),
);
return;
}
}
final selectedBest = best;
@@ -1405,6 +1478,12 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
onTap: _selectEmptyFields,
cs: cs,
),
const SizedBox(width: 8),
_quickSelectButton(
label: context.l10n.editMetadataSelectNone,
onTap: _selectNoFields,
cs: cs,
),
],
),
),
+21 -3
View File
@@ -2921,7 +2921,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
if (!_fileExists) return;
try {
final artistTagMode = ref.read(settingsProvider).artistTagMode;
final settings = ref.read(settingsProvider);
final artistTagMode = settings.artistTagMode;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackReEnrichSearching)),
);
@@ -2931,7 +2932,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
'file_path': cleanFilePath,
'cover_url': _coverUrl ?? '',
'max_quality': true,
'embed_lyrics': true,
'embed_lyrics': settings.embedLyrics,
'lyrics_mode': settings.lyricsMode,
'artist_tag_mode': artistTagMode,
'spotify_id': _spotifyId ?? '',
'track_name': trackName,
@@ -2978,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();
@@ -3072,6 +3080,10 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
return;
}
await writeReEnrichSafSidecarLrc(
safUri: safUri,
reEnrichResult: result,
);
}
if (tempPath != null && tempPath.isNotEmpty) {
@@ -3081,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();
@@ -0,0 +1,52 @@
import 'package:spotiflac_android/services/platform_bridge.dart';
class CrossExtensionShareResult {
final String extensionId;
final String displayName;
final bool found;
final String? url;
final String? itemName;
final String? itemArtists;
final String? error;
const CrossExtensionShareResult({
required this.extensionId,
required this.displayName,
required this.found,
this.url,
this.itemName,
this.itemArtists,
this.error,
});
factory CrossExtensionShareResult.fromJson(Map<String, dynamic> json) {
return CrossExtensionShareResult(
extensionId: json['extension_id'] as String? ?? '',
displayName: json['display_name'] as String? ?? '',
found: json['found'] as bool? ?? false,
url: json['url'] as String?,
itemName: json['item_name'] as String?,
itemArtists: json['item_artists'] as String?,
error: json['error'] as String?,
);
}
}
class CrossExtensionShareService {
const CrossExtensionShareService();
Future<List<CrossExtensionShareResult>> findAcrossExtensions({
required String name,
required String artists,
required String type,
required String sourceExtensionId,
}) async {
final results = await PlatformBridge.findCollectionAcrossExtensions(
name: name,
artists: artists,
type: type,
sourceExtensionId: sourceExtensionId,
);
return results.map(CrossExtensionShareResult.fromJson).toList();
}
}
+8
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
@@ -236,6 +237,7 @@ class NotificationService {
bool alreadyInLibrary = false,
}) async {
if (!_isInitialized) await initialize();
unawaited(HapticFeedback.mediumImpact());
String title;
if (alreadyInLibrary) {
@@ -286,6 +288,11 @@ class NotificationService {
}) async {
if (!_isInitialized) await initialize();
if (completedCount <= 0 && failedCount <= 0) return;
unawaited(
failedCount > 0
? HapticFeedback.heavyImpact()
: HapticFeedback.mediumImpact(),
);
final title = failedCount > 0
? (_l10n?.notifDownloadsFinished(completedCount, failedCount) ??
@@ -330,6 +337,7 @@ class NotificationService {
Future<void> showQueueCanceled({required int canceledCount}) async {
if (!_isInitialized) await initialize();
if (canceledCount <= 0) return;
unawaited(HapticFeedback.lightImpact());
final title = _l10n?.notifDownloadsCanceledTitle ?? 'Downloads canceled';
final body =
+28
View File
@@ -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 = '',
@@ -1300,6 +1309,25 @@ class PlatformBridge {
return _decodeMapListResult(result, 'searchTracksWithMetadataProviders');
}
static Future<List<Map<String, dynamic>>> findCollectionAcrossExtensions({
required String name,
required String artists,
required String type,
required String sourceExtensionId,
}) async {
final requestJson = jsonEncode({
'name': name,
'artists': artists,
'type': type,
'source_extension_id': sourceExtensionId,
});
final result = await _channel.invokeMethod(
'findCollectionAcrossExtensions',
requestJson,
);
return _decodeMapListResult(result, 'findCollectionAcrossExtensions');
}
static Future<void> cleanupExtensions() async {
_log.d('cleanupExtensions');
await _channel.invokeMethod('cleanupExtensions');
+6
View File
@@ -31,6 +31,12 @@ class ShareIntentService {
return url;
}
void injectUrl(String url) {
if (url.isEmpty) return;
_pendingUrl = url;
_sharedUrlController.add(url);
}
Future<void> initialize() async {
if (_initialized) return;
_initialized = true;
+51
View File
@@ -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,
+91 -41
View File
@@ -12,18 +12,45 @@ 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;
await launchUrl(uri, mode: LaunchMode.externalApplication);
onDismiss();
if (context.mounted) {
Navigator.pop(context);
if (uri == null) {
_showCtaOpenFailed(context);
return;
}
bool launched;
try {
launched = await launchUrl(uri, mode: LaunchMode.externalApplication);
} catch (_) {
launched = false;
}
if (!context.mounted) return;
if (!launched) {
_showCtaOpenFailed(context);
return;
}
onDismiss();
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
@@ -31,41 +58,60 @@ class AppAnnouncementDialog extends StatelessWidget {
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 +121,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);
);
}
@@ -0,0 +1,274 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/services/cross_extension_share_service.dart';
import 'package:spotiflac_android/services/share_intent_service.dart';
class CrossExtensionShareSheet extends ConsumerStatefulWidget {
final String name;
final String artists;
final String type;
final String sourceExtensionId;
const CrossExtensionShareSheet({
super.key,
required this.name,
required this.artists,
required this.type,
required this.sourceExtensionId,
});
static Future<void> show(
BuildContext context, {
required String name,
required String artists,
required String type,
required String sourceExtensionId,
}) {
final colorScheme = Theme.of(context).colorScheme;
return showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
isScrollControlled: true,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (_) => CrossExtensionShareSheet(
name: name,
artists: artists,
type: type,
sourceExtensionId: sourceExtensionId,
),
);
}
@override
ConsumerState<CrossExtensionShareSheet> createState() =>
_CrossExtensionShareSheetState();
}
class _CrossExtensionShareSheetState
extends ConsumerState<CrossExtensionShareSheet> {
late final Future<List<CrossExtensionShareResult>> _future;
@override
void initState() {
super.initState();
_future = const CrossExtensionShareService()
.findAcrossExtensions(
name: widget.name,
artists: widget.artists,
type: widget.type,
sourceExtensionId: widget.sourceExtensionId,
)
.then((results) {
final sorted = [...results];
sorted.sort((a, b) {
if (a.found != b.found) return a.found ? -1 : 1;
return a.displayName.compareTo(b.displayName);
});
return sorted;
});
}
String? _iconPathFor(String extensionId) {
if (extensionId.isEmpty) return null;
final extensions = ref.read(extensionProvider).extensions;
for (final ext in extensions) {
if (ext.id == extensionId) {
final path = ext.iconPath;
return (path != null && path.isNotEmpty) ? path : null;
}
}
return null;
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return SafeArea(
top: false,
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(context).height * 0.82,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 8),
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 4),
child: Text(
context.l10n.openInOtherServices,
style: textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
child: Text(
widget.artists.isNotEmpty
? '${widget.name} - ${widget.artists}'
: widget.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
Flexible(
child: FutureBuilder<List<CrossExtensionShareResult>>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const SizedBox(
height: 180,
child: Center(child: CircularProgressIndicator()),
);
}
final results = snapshot.data ?? const [];
if (results.isEmpty) {
return SizedBox(
height: 180,
child: Center(
child: Text(
context.l10n.shareSheetNoExtensions,
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
);
}
return ListView.builder(
shrinkWrap: true,
padding: const EdgeInsets.only(bottom: 16, top: 4),
itemBuilder: (context, index) {
final result = results[index];
return _CrossExtensionShareTile(
result: result,
iconPath: _iconPathFor(result.extensionId),
);
},
itemCount: results.length,
);
},
),
),
],
),
),
);
}
}
class _CrossExtensionShareTile extends StatelessWidget {
final CrossExtensionShareResult result;
final String? iconPath;
const _CrossExtensionShareTile({required this.result, this.iconPath});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final url = result.url;
final hasUrl = result.found && url != null && url.isNotEmpty;
final tile = ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
leading: Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
clipBehavior: Clip.antiAlias,
child: _buildIcon(colorScheme),
),
title: Text(
result.displayName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w600),
),
subtitle: Text(
hasUrl
? (result.itemName?.isNotEmpty == true ? result.itemName! : url)
: context.l10n.shareSheetNotFound,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
trailing: hasUrl
? IconButton(
tooltip: context.l10n.shareSheetCopyLink,
icon: const Icon(Icons.copy_rounded, size: 20),
color: colorScheme.onSurfaceVariant,
onPressed: () {
Clipboard.setData(ClipboardData(text: url));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.shareSheetLinkCopied(result.displayName),
),
),
);
},
)
: null,
onTap: hasUrl
? () {
Navigator.pop(context);
ShareIntentService().injectUrl(url);
}
: null,
);
if (hasUrl) return tile;
return Opacity(opacity: 0.5, child: tile);
}
Widget _buildIcon(ColorScheme colorScheme) {
final fallbackIcon = Icon(
Icons.extension_rounded,
color: colorScheme.onSurfaceVariant,
);
final path = iconPath;
if (path == null) return fallbackIcon;
return Image.file(
File(path),
width: 44,
height: 44,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => fallbackIcon,
);
}
}
+145 -145
View File
@@ -5,42 +5,42 @@ packages:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d
sha256: a49d6cf99e8d8e7a8e93668d09ced0bbdb954d0b4fccc2f5f9241c6b87fad95c
url: "https://pub.dev"
source: hosted
version: "91.0.0"
version: "99.0.0"
analysis_server_plugin:
dependency: transitive
description:
name: analysis_server_plugin
sha256: "26844e7f977087567135d62532b67d5639fe206c5194c3f410ba75e1a04a2747"
sha256: "3960b28ee740004df39f85d5ebfc91785f7a90e51fd7c9a185e86a36b2f581b4"
url: "https://pub.dev"
source: hosted
version: "0.3.3"
version: "0.3.14"
analyzer:
dependency: transitive
description:
name: analyzer
sha256: a40a0cee526a7e1f387c6847bd8a5ccbf510a75952ef8a28338e989558072cb0
sha256: "663efa951fb8a45e06f491223a604c93820598f20e6a99c25617a1576065e8b7"
url: "https://pub.dev"
source: hosted
version: "8.4.0"
version: "12.1.0"
analyzer_buffer:
dependency: transitive
description:
name: analyzer_buffer
sha256: aba2f75e63b3135fd1efaa8b6abefe1aa6e41b6bd9806221620fa48f98156033
sha256: bf559bc54530827a92cc4d9ee340fc76b4f17f386218c2b9e7cd33ed468a7e4d
url: "https://pub.dev"
source: hosted
version: "0.1.11"
version: "0.3.2"
analyzer_plugin:
dependency: transitive
description:
name: analyzer_plugin
sha256: "08cfefa90b4f4dd3b447bda831cecf644029f9f8e22820f6ee310213ebe2dd53"
sha256: "0057a98d64d7bb872b0c87dff6e73d2c2d80c77156e7a03f127a26f8aa240649"
url: "https://pub.dev"
source: hosted
version: "0.13.10"
version: "0.14.8"
archive:
dependency: transitive
description:
@@ -77,10 +77,10 @@ packages:
dependency: transitive
description:
name: build
sha256: aadd943f4f8cc946882c954c187e6115a84c98c81ad1d9c6cbf0895a8c85da9c
sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10
url: "https://pub.dev"
source: hosted
version: "4.0.5"
version: "4.0.6"
build_config:
dependency: transitive
description:
@@ -117,10 +117,10 @@ packages:
dependency: transitive
description:
name: built_value
sha256: "0730c18c770d05636a8f945c32a4d7d81cb6e0f0148c8db4ad12e7748f7e49af"
sha256: "34e4067d30ce212937df995f03b69992eea683539ceeac7f679a1f1eba055b56"
url: "https://pub.dev"
source: hosted
version: "8.12.5"
version: "8.12.6"
cached_network_image:
dependency: "direct main"
description:
@@ -161,14 +161,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.4"
ci:
dependency: transitive
description:
name: ci
sha256: "145d095ce05cddac4d797a158bc4cf3b6016d1fe63d8c3d2fbd7212590adca13"
url: "https://pub.dev"
source: hosted
version: "0.1.0"
cli_config:
dependency: transitive
description:
@@ -197,10 +189,10 @@ packages:
dependency: transitive
description:
name: code_assets
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
sha256: "67cf6d84013f9c601e42a6f8a6b74c4c0d9dc1a1619d775f2b28b732d3551b85"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
version: "1.2.0"
code_builder:
dependency: transitive
description:
@@ -265,62 +257,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.7"
custom_lint:
dependency: "direct dev"
description:
name: custom_lint
sha256: "751ee9440920f808266c3ec2553420dea56d3c7837dd2d62af76b11be3fcece5"
url: "https://pub.dev"
source: hosted
version: "0.8.1"
custom_lint_core:
dependency: transitive
description:
name: custom_lint_core
sha256: "85b339346154d5646952d44d682965dfe9e12cae5febd706f0db3aa5010d6423"
url: "https://pub.dev"
source: hosted
version: "0.8.1"
custom_lint_visitor:
dependency: transitive
description:
name: custom_lint_visitor
sha256: "91f2a81e9f0abb4b9f3bb529f78b6227ce6050300d1ae5b1e2c69c66c7a566d8"
url: "https://pub.dev"
source: hosted
version: "1.0.0+8.4.0"
dart_style:
dependency: transitive
description:
name: dart_style
sha256: a9c30492da18ff84efe2422ba2d319a89942d93e58eb0b73d32abe822ef54b7b
sha256: a4c1ccfee44c7e75ed80484071a5c142a385345e658fd8bd7c4b5c97e7198f98
url: "https://pub.dev"
source: hosted
version: "3.1.3"
version: "3.1.8"
dbus:
dependency: transitive
description:
name: dbus
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
sha256: "792974a4007974fbc5c1b5433eb2330a9db3e368c3f906253af4c007d0f49a91"
url: "https://pub.dev"
source: hosted
version: "0.7.12"
version: "0.7.13"
device_info_plus:
dependency: "direct main"
description:
name: device_info_plus
sha256: b4fed1b2835da9d670d7bed7db79ae2a94b0f5ad6312268158a9b5479abbacdd
sha256: "6a642e1daa10190af89ba6cb6386c0df7d071a3592080bfe1e44faa63ae1df65"
url: "https://pub.dev"
source: hosted
version: "12.4.0"
version: "13.1.0"
device_info_plus_platform_interface:
dependency: transitive
description:
name: device_info_plus_platform_interface
sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f
sha256: "04b173a92e2d9161dfead145667037c8d834db725ce2e7b942bfe18fd2f45a46"
url: "https://pub.dev"
source: hosted
version: "7.0.3"
version: "8.1.0"
dynamic_color:
dependency: "direct main"
description:
@@ -345,6 +313,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
ffi_leak_tracker:
dependency: transitive
description:
name: ffi_leak_tracker
sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97"
url: "https://pub.dev"
source: hosted
version: "0.1.2"
ffmpeg_kit_flutter_new_full:
dependency: "direct main"
description:
@@ -373,10 +349,10 @@ packages:
dependency: "direct main"
description:
name: file_picker
sha256: f13a03000d942e476bc1ff0a736d2e9de711d2f89a95cd4c1d88f861c3348387
sha256: fc83774ce5bd7ce08168333b5e53dbe9090ec04eb21e7aa7cd7bac921032c934
url: "https://pub.dev"
source: hosted
version: "11.0.2"
version: "12.0.0-beta.5"
fixnum:
dependency: transitive
description:
@@ -418,34 +394,42 @@ packages:
dependency: "direct main"
description:
name: flutter_local_notifications
sha256: "0d9035862236fe38250fe1644d7ed3b8254e34a21b2c837c9f539fbb3bba5ef1"
sha256: "3c7aeaded67100c7eecaac46e40097952f1afb8562a3e8b974515ea9cdc5439a"
url: "https://pub.dev"
source: hosted
version: "21.0.0"
version: "22.0.0-dev.3"
flutter_local_notifications_linux:
dependency: transitive
description:
name: flutter_local_notifications_linux
sha256: e0f25e243c6c44c825bbbc6b2b2e76f7d9222362adcfe9fd780bf01923c840bd
sha256: ad8c60755a783d632ac17e42f03053dabbddfe5766f48b5340b1dac159f94d46
url: "https://pub.dev"
source: hosted
version: "8.0.0"
version: "8.0.1-dev.1"
flutter_local_notifications_platform_interface:
dependency: transitive
description:
name: flutter_local_notifications_platform_interface
sha256: e7db3d5b49c2b7ecc68deba4aaaa67a348f92ee0fef34c8e4b4459dbef0d7307
sha256: "21d45c421e4069d26fccfdd7934a5a5155f2ff352e1fbec0f9efbab3eb243646"
url: "https://pub.dev"
source: hosted
version: "11.0.0"
version: "12.0.0-dev.1"
flutter_local_notifications_web:
dependency: transitive
description:
name: flutter_local_notifications_web
sha256: "8868c009ed125ab2990fe7b5cf84c26b9f25132da40303cfa8a0682efb01922c"
url: "https://pub.dev"
source: hosted
version: "1.0.0-dev.2"
flutter_local_notifications_windows:
dependency: transitive
description:
name: flutter_local_notifications_windows
sha256: "3a2654ba104fbb52c618ebed9def24ef270228470718c43b3a6afcd5c81bef0c"
sha256: "2ec0b816f25b5cd59e7a40f88925dbf0fff30944736b71b6920bdd7a421b145e"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
version: "3.1.0-dev.2"
flutter_localizations:
dependency: "direct main"
description: flutter
@@ -463,34 +447,34 @@ packages:
dependency: "direct main"
description:
name: flutter_riverpod
sha256: "38ec6c303e2c83ee84512f5fc2a82ae311531021938e63d7137eccc107bf3c02"
sha256: be3aa640f053064e2238f8a308baa5be7270645e8b53b08484fd305bd5c1eb5d
url: "https://pub.dev"
source: hosted
version: "3.1.0"
version: "3.3.2-dev.2"
flutter_secure_storage:
dependency: "direct main"
description:
name: flutter_secure_storage
sha256: da922f2aab2d733db7e011a6bcc4a825b844892d4edd6df83ff156b09a9b2e40
sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e"
url: "https://pub.dev"
source: hosted
version: "10.0.0"
version: "10.3.1"
flutter_secure_storage_darwin:
dependency: transitive
description:
name: flutter_secure_storage_darwin
sha256: "8878c25136a79def1668c75985e8e193d9d7d095453ec28730da0315dc69aee3"
sha256: "82329fa5cdf343773b1b6897dea959105a29f092454259edff92f9f6637e8149"
url: "https://pub.dev"
source: hosted
version: "0.2.0"
version: "0.3.2"
flutter_secure_storage_linux:
dependency: transitive
description:
name: flutter_secure_storage_linux
sha256: "2b5c76dce569ab752d55a1cee6a2242bcc11fdba927078fb88c503f150767cda"
sha256: a5f35ddab43cf5c8215d2feb4ce1957851f28c5c37e6f04335066a0602087bf5
url: "https://pub.dev"
source: hosted
version: "3.0.0"
version: "3.0.1"
flutter_secure_storage_platform_interface:
dependency: transitive
description:
@@ -503,18 +487,18 @@ packages:
dependency: transitive
description:
name: flutter_secure_storage_web
sha256: "6a1137df62b84b54261dca582c1c09ea72f4f9a4b2fcee21b025964132d5d0c3"
sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
version: "2.1.1"
flutter_secure_storage_windows:
dependency: transitive
description:
name: flutter_secure_storage_windows
sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613"
sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1"
url: "https://pub.dev"
source: hosted
version: "4.1.0"
version: "4.2.2"
flutter_test:
dependency: "direct dev"
description: flutter
@@ -569,10 +553,10 @@ packages:
dependency: transitive
description:
name: hooks
sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388
sha256: a41af4e8fc687cd6d33de9751eb936c8c0204ebe2bcb6c15ecf707504bf47f31
url: "https://pub.dev"
source: hosted
version: "1.0.2"
version: "2.0.0"
http:
dependency: "direct main"
description:
@@ -601,10 +585,10 @@ packages:
dependency: transitive
description:
name: image
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
sha256: "6300175e00616bbc832e2fc91bfa4d776af5402c81c7151bee6905bb08473c52"
url: "https://pub.dev"
source: hosted
version: "4.8.0"
version: "4.9.1"
intl:
dependency: "direct main"
description:
@@ -621,22 +605,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.5"
jni:
dependency: transitive
description:
name: jni
sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f
url: "https://pub.dev"
source: hosted
version: "1.0.0"
jni_flutter:
dependency: transitive
description:
name: jni_flutter
sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
json_annotation:
dependency: "direct main"
description:
name: json_annotation
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80"
url: "https://pub.dev"
source: hosted
version: "4.9.0"
version: "4.12.0"
json_serializable:
dependency: "direct dev"
description:
name: json_serializable
sha256: c5b2ee75210a0f263c6c7b9eeea80553dbae96ea1bf57f02484e806a3ffdffa3
sha256: ffcd10cde35a93b2abbbcc26bd9971f4ca93763e8abe78d855e3c4177797e501
url: "https://pub.dev"
source: hosted
version: "6.11.2"
version: "6.14.0"
leak_tracker:
dependency: transitive
description:
@@ -705,10 +705,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev"
source: hosted
version: "1.17.0"
version: "1.18.0"
mime:
dependency: transitive
description:
@@ -725,14 +725,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.6.4"
native_toolchain_c:
dependency: transitive
description:
name: native_toolchain_c
sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572"
url: "https://pub.dev"
source: hosted
version: "0.17.6"
nm:
dependency: transitive
description:
@@ -753,10 +745,10 @@ packages:
dependency: transitive
description:
name: objective_c
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed"
url: "https://pub.dev"
source: hosted
version: "9.3.0"
version: "9.4.1"
octo_image:
dependency: transitive
description:
@@ -801,10 +793,10 @@ packages:
dependency: transitive
description:
name: path_provider_android
sha256: "149441ca6e4f38193b2e004c0ca6376a3d11f51fa5a77552d8bd4d2b0c0912ba"
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
url: "https://pub.dev"
source: hosted
version: "2.2.23"
version: "2.3.1"
path_provider_foundation:
dependency: transitive
description:
@@ -841,10 +833,10 @@ packages:
dependency: "direct main"
description:
name: permission_handler
sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1
sha256: ca045d03615023c08ccdb297aad46a9198193666039ddd36d4d85fd0b1864b98
url: "https://pub.dev"
source: hosted
version: "12.0.1"
version: "12.0.2"
permission_handler_android:
dependency: transitive
description:
@@ -857,10 +849,10 @@ packages:
dependency: transitive
description:
name: permission_handler_apple
sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023
sha256: "447c18bc3c5fdea5c3039f042b2b365fd51e3634f5f6e269ed22c1f00071addc"
url: "https://pub.dev"
source: hosted
version: "9.4.7"
version: "9.4.8"
permission_handler_html:
dependency: transitive
description:
@@ -949,46 +941,54 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.8.1"
record_use:
dependency: transitive
description:
name: record_use
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
url: "https://pub.dev"
source: hosted
version: "0.6.0"
riverpod:
dependency: transitive
description:
name: riverpod
sha256: "16ff608d21e8ea64364f2b7c049c94a02ab81668f78845862b6e88b71dd4935a"
sha256: da7233961958420e9d80edf4b7a735d5b6b732fe2381d2a12a388562e2042b3f
url: "https://pub.dev"
source: hosted
version: "3.1.0"
version: "3.3.2-dev.2"
riverpod_analyzer_utils:
dependency: transitive
description:
name: riverpod_analyzer_utils
sha256: "947b05d04c52a546a2ac6b19ef2a54b08520ff6bdf9f23d67957a4c8df1c3bc0"
sha256: "3e275138862ccc22ed61444a1f9a840f753094c367f28f4123f50289cd204d68"
url: "https://pub.dev"
source: hosted
version: "1.0.0-dev.8"
version: "1.0.0-dev.10"
riverpod_annotation:
dependency: transitive
description:
name: riverpod_annotation
sha256: cc1474bc2df55ec3c1da1989d139dcef22cd5e2bd78da382e867a69a8eca2e46
sha256: b7fec3dcdef4cc724116b9cad9fd1ca90fb241083cc16e9cf42a85256be7e87e
url: "https://pub.dev"
source: hosted
version: "4.0.0"
version: "4.0.3-dev.2"
riverpod_generator:
dependency: "direct dev"
description:
name: riverpod_generator
sha256: e43b1537229cc8f487f09b0c20d15dba840acbadcf5fc6dad7ad5e8ab75950dc
sha256: "2ba125d0f0ece0b7f2a549613803ea1d4a5e1c8b887588223b19ed8671237504"
url: "https://pub.dev"
source: hosted
version: "4.0.0+1"
version: "4.0.4-dev.3"
riverpod_lint:
dependency: "direct dev"
description:
name: riverpod_lint
sha256: "4d2eb0d19bbe7e3323bd0ce4553b2e6170d161a13914bfdd85a3612329edcb43"
sha256: "4ef15442a9f2254ed6ee59a48db84c2c784580ef804061443861b7e0360ec106"
url: "https://pub.dev"
source: hosted
version: "3.1.0"
version: "3.1.4-dev.3"
rxdart:
dependency: transitive
description:
@@ -1001,18 +1001,18 @@ packages:
dependency: "direct main"
description:
name: share_plus
sha256: "223873d106614442ea6f20db5a038685cc5b32a2fba81cdecaefbbae0523f7fa"
sha256: a857d8b1479250aff6b57a51b2c02d31ca05848d441817c43f1640c885c286c0
url: "https://pub.dev"
source: hosted
version: "12.0.2"
version: "13.1.0"
share_plus_platform_interface:
dependency: transitive
description:
name: share_plus_platform_interface
sha256: "88023e53a13429bd65d8e85e11a9b484f49d4c190abbd96c7932b74d6927cc9a"
sha256: "7f7ae28cf400d13f811e297ff37742dba83b79e0a6f5dce14eec0248274e6ce9"
url: "https://pub.dev"
source: hosted
version: "6.1.0"
version: "7.1.0"
shared_preferences:
dependency: "direct main"
description:
@@ -1110,18 +1110,18 @@ packages:
dependency: transitive
description:
name: source_gen
sha256: "732792cfd197d2161a65bb029606a46e0a18ff30ef9e141a7a82172b05ea8ecd"
sha256: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02
url: "https://pub.dev"
source: hosted
version: "4.2.2"
version: "4.2.3"
source_helper:
dependency: transitive
description:
name: source_helper
sha256: "6a3c6cc82073a8797f8c4dc4572146114a39652851c157db37e964d9c7038723"
sha256: "4227d54ceefd0bb8ca4c8fcb96e1719dc53f1ee1b6e2ca9d7a6069da160e4eae"
url: "https://pub.dev"
source: hosted
version: "1.3.8"
version: "1.3.12"
source_map_stack_trace:
dependency: transitive
description:
@@ -1166,10 +1166,10 @@ packages:
dependency: transitive
description:
name: sqflite_common
sha256: "5e8377564d95166761a968ed96104e0569b6b6cc611faac92a36ab8a169112c3"
sha256: "1581ffbf7a0e333b380d6a30737d78516b826cb35beb7fb0bf8a3ea0c678b465"
url: "https://pub.dev"
source: hosted
version: "2.5.6+1"
version: "2.5.8"
sqflite_darwin:
dependency: transitive
description:
@@ -1230,10 +1230,10 @@ packages:
dependency: transitive
description:
name: synchronized
sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0
sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5"
url: "https://pub.dev"
source: hosted
version: "3.4.0"
version: "3.4.0+1"
term_glyph:
dependency: transitive
description:
@@ -1246,26 +1246,26 @@ packages:
dependency: transitive
description:
name: test
sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7"
sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20"
url: "https://pub.dev"
source: hosted
version: "1.30.0"
version: "1.31.0"
test_api:
dependency: transitive
description:
name: test_api
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev"
source: hosted
version: "0.7.10"
version: "0.7.11"
test_core:
dependency: transitive
description:
name: test_core
sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51"
sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34"
url: "https://pub.dev"
source: hosted
version: "0.6.16"
version: "0.6.17"
timezone:
dependency: transitive
description:
@@ -1294,10 +1294,10 @@ packages:
dependency: transitive
description:
name: url_launcher_android
sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572"
sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c"
url: "https://pub.dev"
source: hosted
version: "6.3.29"
version: "6.3.30"
url_launcher_ios:
dependency: transitive
description:
@@ -1334,10 +1334,10 @@ packages:
dependency: transitive
description:
name: url_launcher_web
sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f
sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
version: "2.4.3"
url_launcher_windows:
dependency: transitive
description:
@@ -1366,10 +1366,10 @@ packages:
dependency: transitive
description:
name: vm_service
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
url: "https://pub.dev"
source: hosted
version: "15.0.2"
version: "15.2.0"
watcher:
dependency: transitive
description:
@@ -1414,18 +1414,18 @@ packages:
dependency: transitive
description:
name: win32
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738
url: "https://pub.dev"
source: hosted
version: "5.15.0"
version: "6.3.0"
win32_registry:
dependency: transitive
description:
name: win32_registry
sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae"
sha256: "73b1d78920a9d6e03f8b4e43e612b87bf3152a0e5c5e5150267762b7c4116904"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
version: "3.0.3"
xdg_directories:
dependency: transitive
description:
@@ -1438,10 +1438,10 @@ packages:
dependency: transitive
description:
name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4"
url: "https://pub.dev"
source: hosted
version: "6.6.1"
version: "7.0.1"
yaml:
dependency: transitive
description:
@@ -1459,5 +1459,5 @@ packages:
source: hosted
version: "2.2.4"
sdks:
dart: ">=3.10.3 <4.0.0"
dart: ">=3.11.0 <4.0.0"
flutter: ">=3.38.4"
+11 -12
View File
@@ -1,7 +1,7 @@
name: spotiflac_android
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Deezer
publish_to: "none"
version: 4.5.5+132
version: 4.5.6+133
environment:
sdk: ^3.10.0
@@ -16,14 +16,14 @@ dependencies:
intl: ^0.20.2
# State Management
flutter_riverpod: ^3.1.0
flutter_riverpod: ^3.3.1
# Navigation
go_router: ^17.2.3
# Storage & Persistence
shared_preferences: ^2.5.3
flutter_secure_storage: 10.0.0
flutter_secure_storage: ^10.3.1
path_provider: ^2.1.5
path: ^1.9.0
sqflite: ^2.4.2+1
@@ -43,15 +43,15 @@ dependencies:
permission_handler: ^12.0.1
# File Picker
file_picker: ^11.0.2
file_picker: ^12.0.0-beta.5
# JSON Serialization
json_annotation: ^4.9.0
json_annotation: ^4.11.0
# Utils
url_launcher: ^6.3.1
device_info_plus: ^12.3.0
share_plus: ^12.0.1
device_info_plus: ^13.1.0
share_plus: ^13.1.0
receive_sharing_intent: ^1.8.1
logger: ^2.5.0
@@ -60,17 +60,16 @@ dependencies:
open_filex: ^4.7.0
# Notifications
flutter_local_notifications: 21.0.0
flutter_local_notifications: ^22.0.0-dev.3
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0
build_runner: ^2.15.0
custom_lint: ^0.8.1
riverpod_generator: ^4.0.0
riverpod_lint: ^3.1.0
json_serializable: ^6.11.2
riverpod_generator: ^4.0.4-dev.3
riverpod_lint: ^3.1.4-dev.3
json_serializable: ^6.14.0
flutter_launcher_icons: ^0.14.3
flutter_launcher_icons:
+1 -1
View File
@@ -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,