mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-03 19:27:57 +02:00
Compare commits
94 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 23cab16471 | |||
| 0a892011de | |||
| acb1d957d3 | |||
| 4a492aeefc | |||
| eb143a41fc | |||
| 75db2f162b | |||
| 855d0e3ffc | |||
| 5ccd06cc68 | |||
| 66a89d9e8e | |||
| 814deca19d | |||
| 3bb6754d9c | |||
| 7d11d67cd2 | |||
| c0bd10cfca | |||
| e003b15ffd | |||
| ac1c7d31c9 | |||
| 6fc9ffeb23 | |||
| 9bebed506b | |||
| c66d13c9fd | |||
| 8529985a0e | |||
| a8a3973225 | |||
| 6710f90e1e | |||
| 929c5f3249 | |||
| f170ead7b9 | |||
| e63e366228 | |||
| 95e755e54e | |||
| c719406425 | |||
| 9627ef66cf | |||
| 15f977d98d | |||
| 5b5f043624 | |||
| 529a920b24 | |||
| 09eb6cf206 | |||
| af6fa6ea53 | |||
| 280b921755 | |||
| 6ebe0c51ce | |||
| 47bd24c1bd | |||
| 2b23678c0d | |||
| e8327545ad | |||
| 89a38af538 | |||
| b7f34ec47c | |||
| 967523bfc6 | |||
| 29d8a185f9 | |||
| 4495d4bf4e | |||
| 67737467e0 | |||
| 13845eea04 | |||
| 12779778d3 | |||
| d4178ad036 | |||
| 49ea84384d | |||
| a6d9849468 | |||
| 16100aa0fd | |||
| 387dd47374 | |||
| 6ecb69feae | |||
| feff985439 | |||
| 2e8fe34824 | |||
| f58005f406 | |||
| 75abc03a4f | |||
| 84381d142a | |||
| f67f52eba9 | |||
| 3747ffff64 | |||
| ed47efed17 | |||
| c0d72e89d7 | |||
| a4313cfe0f | |||
| c7bef03ee3 | |||
| ce5a9e0cff | |||
| 859b823e77 | |||
| 7d8cf5f7ca | |||
| 4adaed8da0 | |||
| 554fe08fcd | |||
| b8af75bf6e | |||
| 35f2f119db | |||
| f36096e0ac | |||
| 1665e4cd57 | |||
| 42f0267277 | |||
| 82f59d32b9 | |||
| 941347b007 | |||
| 739c89569f | |||
| 18607597e9 | |||
| 7bb808cba5 | |||
| 78cd396847 | |||
| bb342c01e2 | |||
| 8a5dc0edfe | |||
| 8540da484f | |||
| 20f789f8e0 | |||
| 3e89326c95 | |||
| a7ea4de25a | |||
| aabfbf062e | |||
| 7b9ed3ec8e | |||
| 6dad66d62d | |||
| 31018230ee | |||
| 54ddc1f59c | |||
| c6856bd1a1 | |||
| 8c18c7b8f1 | |||
| 10c5293f64 | |||
| c347b6999e | |||
| adc74741ce |
@@ -393,6 +393,63 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
update-altstore:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [get-version, build-ios, create-release]
|
||||
if: ${{ needs.get-version.outputs.is_prerelease != 'true' }}
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout main branch
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- name: Download iOS IPA
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: ios-ipa
|
||||
path: ./release
|
||||
|
||||
- name: Update apps.json
|
||||
run: |
|
||||
VERSION="${{ needs.get-version.outputs.version }}"
|
||||
VERSION_NUM="${VERSION#v}"
|
||||
DATE=$(date -u +%Y-%m-%d)
|
||||
IPA_FILE=$(find ./release -name "*ios*.ipa" | head -1)
|
||||
|
||||
if [ -z "$IPA_FILE" ]; then
|
||||
echo "WARNING: IPA file not found, skipping apps.json update"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
IPA_SIZE=$(stat -c%s "$IPA_FILE" 2>/dev/null || stat -f%z "$IPA_FILE")
|
||||
|
||||
if [ ! -f apps.json ]; then
|
||||
echo "WARNING: apps.json not found on main, skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
jq --arg ver "$VERSION_NUM" \
|
||||
--arg date "$DATE" \
|
||||
--arg url "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/${VERSION}/SpotiFLAC-${VERSION}-ios-unsigned.ipa" \
|
||||
--argjson size "$IPA_SIZE" \
|
||||
'.apps[0].version = $ver | .apps[0].versionDate = $date | .apps[0].downloadURL = $url | .apps[0].size = $size' \
|
||||
apps.json > apps.json.tmp && mv apps.json.tmp apps.json
|
||||
|
||||
echo "Updated apps.json:"
|
||||
cat apps.json
|
||||
|
||||
- name: Commit and push
|
||||
run: |
|
||||
VERSION="${{ needs.get-version.outputs.version }}"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add apps.json
|
||||
git diff --cached --quiet && echo "No changes to commit" || \
|
||||
(git commit -m "chore: update AltStore source to ${VERSION}" && git push)
|
||||
|
||||
notify-telegram:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [get-version, create-release]
|
||||
|
||||
@@ -77,3 +77,6 @@ flutter_*.log
|
||||
# Development tools
|
||||
tool/
|
||||
.claude/settings.local.json
|
||||
|
||||
# FVM Version Cache
|
||||
.fvm/
|
||||
|
||||
+2
-2
@@ -334,7 +334,7 @@ Thank you for your understanding and continued support. This decision was made t
|
||||
- Routing priority: YouTube service -> extension fallback -> built-in fallback -> direct service
|
||||
- New Android method channel handler: `"downloadByStrategy"` -> `Gobackend.downloadByStrategy(...)`
|
||||
- SpotFetch metadata fallback integration for Spotify-blocked regions
|
||||
- New backend client for `spotify.afkarxyz.fun/api`
|
||||
- New backend client for `sp.afkarxyz.qzz.io/api`
|
||||
- Automatic fallback in Spotify metadata fetch path when primary source fails
|
||||
- Lyrics extraction now supports MP3 (ID3v2) and Opus/OGG (Vorbis comments) in addition to FLAC
|
||||
- Includes heuristic detection of lyrics stored in Comment fields
|
||||
@@ -349,7 +349,7 @@ Thank you for your understanding and continued support. This decision was made t
|
||||
- Legacy Dart bridge methods (`downloadTrack`, `downloadWithFallback`, `downloadWithExtensions`, `downloadFromYouTube`) are now thin wrappers and marked `@Deprecated`
|
||||
- Qobuz downloader updated to latest Jumo API contract (`/get` endpoint, required headers)
|
||||
- Amazon download flow now returns `decryption_key` from Go and performs decryption in Flutter (local file + SAF paths)
|
||||
- Amazon now uses the new `amazon.afkarxyz.fun` API flow (ASIN-based track endpoint + legacy fallback) with encrypted stream support
|
||||
- Amazon now uses the new `amzn.afkarxyz.qzz.io` API flow (ASIN-based track endpoint + legacy fallback) with encrypted stream support
|
||||
- Amazon ASIN extraction rewritten with robust URL/query-param parsing and regex fallback
|
||||
- Amazon provider re-enabled in download service picker and download settings (alongside Tidal, Qobuz, and YouTube picker flow)
|
||||
- Track Metadata cover UI now refreshes from the embedded file after Edit Metadata/Re-enrich, so the displayed art matches actual file tags
|
||||
|
||||
+17
-3
@@ -86,17 +86,31 @@ Translation files are located in `lib/l10n/arb/`.
|
||||
git remote add upstream https://github.com/zarzet/SpotiFLAC-Mobile.git
|
||||
```
|
||||
|
||||
3. **Install dependencies**
|
||||
3. **Use FVM (Flutter Version: 3.38.1)**
|
||||
```bash
|
||||
fvm use
|
||||
```
|
||||
|
||||
4. **Install dependencies**
|
||||
```bash
|
||||
flutter pub get
|
||||
```
|
||||
|
||||
4. **Generate code** (for Riverpod, JSON serialization, etc.)
|
||||
5. **Generate code** (for Riverpod, JSON serialization, etc.)
|
||||
```bash
|
||||
dart run build_runner build --delete-conflicting-outputs
|
||||
```
|
||||
|
||||
5. **Run the app**
|
||||
6. **Set up Go environment (Go Version: 1.25.7)**
|
||||
```bash
|
||||
cd go_backend
|
||||
mkdir -p ../android/app/libs
|
||||
gomobile init
|
||||
gomobile bind -target=android -androidapi 24 -o ../android/app/libs/gobackend.aar .
|
||||
cd ..
|
||||
```
|
||||
|
||||
7. **Run the app**
|
||||
```bash
|
||||
flutter run
|
||||
```
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<div align="center">
|
||||
|
||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||
[](https://www.virustotal.com/gui/file/0a2bd2a033551983fc9fcd83f82fd912c83914fd1094cd8d1c7c6a68eb23233f)
|
||||
[](https://www.virustotal.com/gui/file/63a445a956fa71ea347ad3695a62d543e14e341933326b9dbb9a15d79614ef58)
|
||||
[](https://crowdin.com/project/spotiflac-mobile)
|
||||
|
||||
[](https://t.me/spotiflac)
|
||||
@@ -40,10 +40,11 @@ Extensions allow the community to add new music sources and features without wai
|
||||
|
||||
### Installing Extensions
|
||||
1. Go to **Store** tab in the app
|
||||
2. Browse and install extensions with one tap
|
||||
3. Or download a `.spotiflac-ext` file and install manually via **Settings > Extensions**
|
||||
4. Configure extension settings if needed
|
||||
5. Set provider priority in **Settings > Extensions > Provider Priority**
|
||||
2. When opening the Store for the first time, you will be asked to enter an **Extension Repository URL**
|
||||
3. Browse and install extensions with one tap
|
||||
4. Or download a `.spotiflac-ext` file and install manually via **Settings > Extensions**
|
||||
5. Configure extension settings if needed
|
||||
6. Set provider priority in **Settings > Extensions > Provider Priority**
|
||||
|
||||
### Developing Extensions
|
||||
Want to create your own extension? Check out the [Extension Development Guide](https://zarzet.github.io/SpotiFLAC-Mobile/docs) for complete documentation.
|
||||
@@ -55,6 +56,9 @@ Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music for Window
|
||||
|
||||
## FAQ
|
||||
|
||||
**Q: Why does the Store tab ask me to enter a URL?**
|
||||
A: Starting from version 3.8.0, SpotiFLAC uses a decentralized extension repository system — extensions are hosted on GitHub repositories rather than a built-in server, so anyone can create and host their own. Enter a repository URL in the Store tab to browse and install extensions.
|
||||
|
||||
**Q: Why is my download failing with "Song not found"?**
|
||||
A: The track may not be available on the streaming services. Try enabling more download services in Settings > Download > Provider Priority, or install additional extensions like Amazon Music from the Store.
|
||||
|
||||
@@ -73,6 +77,11 @@ A: Yes, the app is open source and you can verify the code yourself. Each releas
|
||||
**Q: Why is download not working in my country?**
|
||||
A: Some countries have restricted access to certain streaming service APIs. If downloads are failing, try using a VPN to connect through a different region.
|
||||
|
||||
**Q: Can I add SpotiFLAC to AltStore or SideStore?**
|
||||
A: Yes! You can add the official source to receive updates directly within the app. Just copy this link:
|
||||
https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/refs/heads/main/apps.json
|
||||
In AltStore/SideStore, go to the Browse tab, tap Sources at the top, then tap the + icon and paste the link.
|
||||
|
||||
|
||||
### Want to support SpotiFLAC-Mobile?
|
||||
|
||||
@@ -80,6 +89,18 @@ _If this software is useful and brings you value, consider supporting the projec
|
||||
|
||||
[](https://ko-fi.com/zarzet)
|
||||
|
||||
## Contributors
|
||||
|
||||
Thanks to all the amazing people who have contributed to SpotiFLAC Mobile!
|
||||
|
||||
<a href="https://github.com/zarzet/SpotiFLAC-Mobile/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=zarzet/SpotiFLAC-Mobile" />
|
||||
</a>
|
||||
|
||||
We also appreciate everyone who has helped with [translations on Crowdin](https://crowdin.com/project/spotiflac-mobile), reported bugs, suggested features, and spread the word about SpotiFLAC Mobile.
|
||||
|
||||
Interested in contributing? Check out our [Contributing Guide](CONTRIBUTING.md) to get started!
|
||||
|
||||
## API Credits
|
||||
|
||||
[hifi-api](https://github.com/binimum/hifi-api) · [music.binimum.org](https://music.binimum.org) · [qqdl.site](https://qqdl.site) · [squid.wtf](https://squid.wtf) · [spotisaver.net](https://spotisaver.net) · [dabmusic.xyz](https://dabmusic.xyz) · [AfkarXYZ](https://github.com/afkarxyz) · [LRCLib](https://lrclib.net) · [Paxsenix](https://lyrics.paxsenix.org) · [Cobalt](https://cobalt.tools) · [qwkuns.me](https://qwkuns.me) · [SpotubeDL](https://spotubedl.com) · [Song.link](https://song.link) · [IDHS](https://github.com/sjdonado/idonthavespotify)
|
||||
@@ -87,4 +108,4 @@ _If this software is useful and brings you value, consider supporting the projec
|
||||
|
||||
> [!TIP]
|
||||
>
|
||||
> **Star Us**, You will receive all release notifications from GitHub without any delay ~
|
||||
> **Star Us**, You will receive all release notifications from GitHub without any delay
|
||||
|
||||
@@ -30,6 +30,7 @@ import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.security.MessageDigest
|
||||
import java.util.Locale
|
||||
|
||||
class MainActivity: FlutterFragmentActivity() {
|
||||
@@ -111,6 +112,13 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildStableLibraryId(filePath: String): String {
|
||||
val digest = MessageDigest.getInstance("SHA-1")
|
||||
val bytes = digest.digest(filePath.toByteArray(Charsets.UTF_8))
|
||||
val hex = bytes.joinToString("") { "%02x".format(it) }
|
||||
return "lib_$hex"
|
||||
}
|
||||
|
||||
data class SafScanProgress(
|
||||
var totalFiles: Int = 0,
|
||||
var scannedFiles: Int = 0,
|
||||
@@ -1263,7 +1271,9 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
} else {
|
||||
try {
|
||||
val lastModified = try { doc.lastModified() } catch (_: Exception) { 0L }
|
||||
metadataObj.put("filePath", doc.uri.toString())
|
||||
val stableUri = doc.uri.toString()
|
||||
metadataObj.put("id", buildStableLibraryId(stableUri))
|
||||
metadataObj.put("filePath", stableUri)
|
||||
metadataObj.put("fileModTime", lastModified)
|
||||
results.put(metadataObj)
|
||||
} catch (_: Exception) {
|
||||
@@ -1680,7 +1690,9 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
} else {
|
||||
try {
|
||||
val safeLastModified = try { doc.lastModified() } catch (_: Exception) { lastModified }
|
||||
metadataObj.put("filePath", doc.uri.toString())
|
||||
val stableUri = doc.uri.toString()
|
||||
metadataObj.put("id", buildStableLibraryId(stableUri))
|
||||
metadataObj.put("filePath", stableUri)
|
||||
metadataObj.put("fileModTime", safeLastModified)
|
||||
metadataObj.put("lastModified", safeLastModified)
|
||||
results.put(metadataObj)
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "SpotiFLAC Source",
|
||||
"identifier": "com.zarzet.spotiflac.source",
|
||||
"subtitle": "FLAC Downloader for iOS",
|
||||
"apps": [
|
||||
{
|
||||
"name": "SpotiFLAC",
|
||||
"bundleIdentifier": "com.zarzet.spotiflac",
|
||||
"developerName": "zarzet",
|
||||
"version": "3.8.6",
|
||||
"versionDate": "2026-03-16",
|
||||
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v3.8.6/SpotiFLAC-v3.8.6-ios-unsigned.ipa",
|
||||
"localizedDescription": "Mobile version of SpotiFLAC 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": 33676960
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -256,6 +256,7 @@ type deezerAlbumFull struct {
|
||||
NbTracks int `json:"nb_tracks"`
|
||||
RecordType string `json:"record_type"`
|
||||
Label string `json:"label"`
|
||||
Copyright string `json:"copyright"`
|
||||
Genres struct {
|
||||
Data []deezerGenre `json:"data"`
|
||||
} `json:"genres"`
|
||||
@@ -1084,8 +1085,9 @@ func (c *DeezerClient) getBestAlbumImage(album deezerAlbumFull) string {
|
||||
}
|
||||
|
||||
type AlbumExtendedMetadata struct {
|
||||
Genre string
|
||||
Label string
|
||||
Genre string
|
||||
Label string
|
||||
Copyright string
|
||||
}
|
||||
|
||||
func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID string) (*AlbumExtendedMetadata, error) {
|
||||
@@ -1116,8 +1118,9 @@ func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID str
|
||||
}
|
||||
|
||||
result := &AlbumExtendedMetadata{
|
||||
Genre: strings.Join(genres, ", "),
|
||||
Label: album.Label,
|
||||
Genre: strings.Join(genres, ", "),
|
||||
Label: album.Label,
|
||||
Copyright: album.Copyright,
|
||||
}
|
||||
|
||||
c.cacheMu.Lock()
|
||||
@@ -1129,7 +1132,7 @@ func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID str
|
||||
c.maybeCleanupCachesLocked(now)
|
||||
c.cacheMu.Unlock()
|
||||
|
||||
GoLog("[Deezer] Album metadata fetched - Genre: %s, Label: %s\n", result.Genre, result.Label)
|
||||
GoLog("[Deezer] Album metadata fetched - Genre: %s, Label: %s, Copyright: %s\n", result.Genre, result.Label, result.Copyright)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -203,29 +203,48 @@ func resolveDeezerTrackURL(req DownloadRequest) (string, error) {
|
||||
}
|
||||
}
|
||||
if deezerID != "" {
|
||||
return fmt.Sprintf("https://www.deezer.com/track/%s", deezerID), nil
|
||||
trackURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerID)
|
||||
if err := verifyDeezerTrack(req, deezerID); err != nil {
|
||||
GoLog("[Deezer] Direct ID %s verification failed: %v\n", deezerID, err)
|
||||
// Don't reject direct IDs from request payload — they're presumably correct.
|
||||
}
|
||||
return trackURL, nil
|
||||
}
|
||||
|
||||
// Try resolving Deezer ID from Spotify ID via SongLink
|
||||
// Try SongLink
|
||||
spotifyID := strings.TrimSpace(req.SpotifyID)
|
||||
if spotifyID != "" && isLikelySpotifyTrackID(spotifyID) {
|
||||
songlink := NewSongLinkClient()
|
||||
availability, err := songlink.CheckTrackAvailability(spotifyID, "")
|
||||
if err == nil && availability.Deezer && availability.DeezerURL != "" {
|
||||
return availability.DeezerURL, nil
|
||||
resolvedID := extractDeezerIDFromURL(availability.DeezerURL)
|
||||
if resolvedID != "" {
|
||||
if verifyErr := verifyDeezerTrack(req, resolvedID); verifyErr != nil {
|
||||
GoLog("[Deezer] SongLink ID %s rejected: %v\n", resolvedID, verifyErr)
|
||||
// Fall through to ISRC search instead of using wrong track.
|
||||
} else {
|
||||
return availability.DeezerURL, nil
|
||||
}
|
||||
} else {
|
||||
return availability.DeezerURL, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try resolving from ISRC
|
||||
// Try ISRC
|
||||
isrc := strings.TrimSpace(req.ISRC)
|
||||
if isrc != "" {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
|
||||
defer cancel()
|
||||
track, err := GetDeezerClient().SearchByISRC(ctx, isrc)
|
||||
if err == nil && track != nil {
|
||||
deezerID = songLinkExtractDeezerTrackID(track)
|
||||
if deezerID != "" {
|
||||
return fmt.Sprintf("https://www.deezer.com/track/%s", deezerID), nil
|
||||
resolvedID := songLinkExtractDeezerTrackID(track)
|
||||
if resolvedID != "" {
|
||||
if verifyErr := verifyDeezerTrack(req, resolvedID); verifyErr != nil {
|
||||
GoLog("[Deezer] ISRC-resolved ID %s rejected: %v\n", resolvedID, verifyErr)
|
||||
return "", fmt.Errorf("deezer track resolved via ISRC does not match: %w", verifyErr)
|
||||
}
|
||||
return fmt.Sprintf("https://www.deezer.com/track/%s", resolvedID), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -233,6 +252,26 @@ func resolveDeezerTrackURL(req DownloadRequest) (string, error) {
|
||||
return "", fmt.Errorf("could not resolve Deezer track URL")
|
||||
}
|
||||
|
||||
func verifyDeezerTrack(req DownloadRequest, deezerID string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
|
||||
defer cancel()
|
||||
trackResp, err := GetDeezerClient().GetTrack(ctx, deezerID)
|
||||
if err != nil {
|
||||
return nil // Can't verify — don't block the download.
|
||||
}
|
||||
resolved := resolvedTrackInfo{
|
||||
Title: trackResp.Track.Name,
|
||||
ArtistName: trackResp.Track.Artists,
|
||||
Duration: trackResp.Track.DurationMS / 1000,
|
||||
}
|
||||
if !trackMatchesRequest(req, resolved, "Deezer") {
|
||||
return fmt.Errorf("expected '%s - %s', got '%s - %s'",
|
||||
req.ArtistName, req.TrackName, resolved.ArtistName, resolved.Title)
|
||||
}
|
||||
GoLog("[Deezer] Track %s verified: '%s - %s' ✓\n", deezerID, resolved.ArtistName, resolved.Title)
|
||||
return nil
|
||||
}
|
||||
|
||||
type deezerMusicDLRequest struct {
|
||||
Platform string `json:"platform"`
|
||||
URL string `json:"url"`
|
||||
|
||||
+106
-30
@@ -135,6 +135,36 @@ type DownloadResult struct {
|
||||
DecryptionKey string
|
||||
}
|
||||
|
||||
func preferredReleaseMetadata(
|
||||
req DownloadRequest,
|
||||
album string,
|
||||
releaseDate string,
|
||||
trackNumber int,
|
||||
discNumber int,
|
||||
) (string, string, int, int) {
|
||||
preferredAlbum := strings.TrimSpace(req.AlbumName)
|
||||
if preferredAlbum == "" {
|
||||
preferredAlbum = album
|
||||
}
|
||||
|
||||
preferredReleaseDate := strings.TrimSpace(req.ReleaseDate)
|
||||
if preferredReleaseDate == "" {
|
||||
preferredReleaseDate = releaseDate
|
||||
}
|
||||
|
||||
preferredTrackNumber := req.TrackNumber
|
||||
if preferredTrackNumber == 0 {
|
||||
preferredTrackNumber = trackNumber
|
||||
}
|
||||
|
||||
preferredDiscNumber := req.DiscNumber
|
||||
if preferredDiscNumber == 0 {
|
||||
preferredDiscNumber = discNumber
|
||||
}
|
||||
|
||||
return preferredAlbum, preferredReleaseDate, preferredTrackNumber, preferredDiscNumber
|
||||
}
|
||||
|
||||
func buildDownloadSuccessResponse(
|
||||
req DownloadRequest,
|
||||
result DownloadResult,
|
||||
@@ -153,25 +183,16 @@ func buildDownloadSuccessResponse(
|
||||
artist = req.ArtistName
|
||||
}
|
||||
|
||||
album := result.Album
|
||||
if album == "" {
|
||||
album = req.AlbumName
|
||||
}
|
||||
|
||||
releaseDate := result.ReleaseDate
|
||||
if releaseDate == "" {
|
||||
releaseDate = req.ReleaseDate
|
||||
}
|
||||
|
||||
trackNumber := result.TrackNumber
|
||||
if trackNumber == 0 {
|
||||
trackNumber = req.TrackNumber
|
||||
}
|
||||
|
||||
discNumber := result.DiscNumber
|
||||
if discNumber == 0 {
|
||||
discNumber = req.DiscNumber
|
||||
}
|
||||
// Preserve requested release metadata when available so mixed-provider
|
||||
// fallback downloads from the same source album do not get split into
|
||||
// different albums just because Tidal/Qobuz report variant titles/dates.
|
||||
album, releaseDate, trackNumber, discNumber := preferredReleaseMetadata(
|
||||
req,
|
||||
result.Album,
|
||||
result.ReleaseDate,
|
||||
result.TrackNumber,
|
||||
result.DiscNumber,
|
||||
)
|
||||
|
||||
isrc := result.ISRC
|
||||
if isrc == "" {
|
||||
@@ -262,7 +283,7 @@ func enrichRequestExtendedMetadata(req *DownloadRequest) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.ISRC == "" || (req.Genre != "" && req.Label != "") {
|
||||
if req.ISRC == "" || (req.Genre != "" && req.Label != "" && req.Copyright != "") {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -284,8 +305,11 @@ func enrichRequestExtendedMetadata(req *DownloadRequest) {
|
||||
if req.Label == "" && extMeta.Label != "" {
|
||||
req.Label = extMeta.Label
|
||||
}
|
||||
if req.Genre != "" || req.Label != "" {
|
||||
GoLog("[DownloadWithFallback] Extended metadata ready: genre=%s, label=%s\n", req.Genre, req.Label)
|
||||
if req.Copyright == "" && extMeta.Copyright != "" {
|
||||
req.Copyright = extMeta.Copyright
|
||||
}
|
||||
if req.Genre != "" || req.Label != "" || req.Copyright != "" {
|
||||
GoLog("[DownloadWithFallback] Extended metadata ready: genre=%s, label=%s, copyright=%s\n", req.Genre, req.Label, req.Copyright)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1314,10 +1338,7 @@ func GetDeezerExtendedMetadata(trackID string) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
result := map[string]string{
|
||||
"genre": metadata.Genre,
|
||||
"label": metadata.Label,
|
||||
}
|
||||
result := buildDeezerExtendedMetadataResult(metadata)
|
||||
|
||||
jsonBytes, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
@@ -1337,7 +1358,8 @@ func SearchDeezerByISRC(isrc string) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(track)
|
||||
result := buildDeezerISRCSearchResult(track)
|
||||
jsonBytes, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -1345,6 +1367,55 @@ func SearchDeezerByISRC(isrc string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func buildDeezerExtendedMetadataResult(metadata *AlbumExtendedMetadata) map[string]string {
|
||||
if metadata == nil {
|
||||
return map[string]string{
|
||||
"genre": "",
|
||||
"label": "",
|
||||
"copyright": "",
|
||||
}
|
||||
}
|
||||
|
||||
return map[string]string{
|
||||
"genre": metadata.Genre,
|
||||
"label": metadata.Label,
|
||||
"copyright": metadata.Copyright,
|
||||
}
|
||||
}
|
||||
|
||||
func buildDeezerISRCSearchResult(track *TrackMetadata) map[string]interface{} {
|
||||
if track == nil {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"spotify_id": track.SpotifyID,
|
||||
"artists": track.Artists,
|
||||
"name": track.Name,
|
||||
"album_name": track.AlbumName,
|
||||
"album_artist": track.AlbumArtist,
|
||||
"duration_ms": track.DurationMS,
|
||||
"images": track.Images,
|
||||
"release_date": track.ReleaseDate,
|
||||
"track_number": track.TrackNumber,
|
||||
"total_tracks": track.TotalTracks,
|
||||
"disc_number": track.DiscNumber,
|
||||
"external_urls": track.ExternalURL,
|
||||
"isrc": track.ISRC,
|
||||
"album_id": track.AlbumID,
|
||||
"artist_id": track.ArtistID,
|
||||
"album_type": track.AlbumType,
|
||||
}
|
||||
|
||||
if deezerID := strings.TrimSpace(strings.TrimPrefix(track.SpotifyID, "deezer:")); deezerID != "" {
|
||||
result["id"] = deezerID
|
||||
result["track_id"] = deezerID
|
||||
result["success"] = true
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
@@ -1623,6 +1694,8 @@ func ExtractCoverToFile(audioPath string, outputPath string) error {
|
||||
|
||||
if strings.HasSuffix(lower, ".flac") {
|
||||
coverData, err = ExtractCoverArt(audioPath)
|
||||
} else if strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".aac") {
|
||||
coverData, err = extractCoverFromM4A(audioPath)
|
||||
} else if strings.HasSuffix(lower, ".mp3") {
|
||||
coverData, _, err = extractMP3CoverArt(audioPath)
|
||||
} else if strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg") {
|
||||
@@ -1803,8 +1876,8 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
||||
GoLog("[ReEnrich] Metadata provider search failed: %v\n", searchErr)
|
||||
}
|
||||
|
||||
// Try to get extended metadata (genre, label) from Deezer if not already set
|
||||
if found && req.ISRC != "" && (req.Genre == "" || req.Label == "") {
|
||||
// Try to get extended metadata from Deezer if not already set
|
||||
if found && req.ISRC != "" && (req.Genre == "" || req.Label == "" || req.Copyright == "") {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
extMeta, err := deezerClient.GetExtendedMetadataByISRC(ctx, req.ISRC)
|
||||
cancel()
|
||||
@@ -1815,7 +1888,10 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
||||
if req.Label == "" && extMeta.Label != "" {
|
||||
req.Label = extMeta.Label
|
||||
}
|
||||
GoLog("[ReEnrich] Extended metadata: genre=%s, label=%s\n", req.Genre, req.Label)
|
||||
if req.Copyright == "" && extMeta.Copyright != "" {
|
||||
req.Copyright = extMeta.Copyright
|
||||
}
|
||||
GoLog("[ReEnrich] Extended metadata: genre=%s, label=%s, copyright=%s\n", req.Genre, req.Label, req.Copyright)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package gobackend
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBuildDeezerExtendedMetadataResultHandlesNil(t *testing.T) {
|
||||
result := buildDeezerExtendedMetadataResult(nil)
|
||||
|
||||
if result["genre"] != "" {
|
||||
t.Fatalf("expected empty genre, got %q", result["genre"])
|
||||
}
|
||||
if result["label"] != "" {
|
||||
t.Fatalf("expected empty label, got %q", result["label"])
|
||||
}
|
||||
if result["copyright"] != "" {
|
||||
t.Fatalf("expected empty copyright, got %q", result["copyright"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDeezerExtendedMetadataResultIncludesCopyright(t *testing.T) {
|
||||
result := buildDeezerExtendedMetadataResult(&AlbumExtendedMetadata{
|
||||
Genre: "Rock",
|
||||
Label: "EMI",
|
||||
Copyright: "(C) Queen",
|
||||
})
|
||||
|
||||
if result["genre"] != "Rock" {
|
||||
t.Fatalf("unexpected genre: %q", result["genre"])
|
||||
}
|
||||
if result["label"] != "EMI" {
|
||||
t.Fatalf("unexpected label: %q", result["label"])
|
||||
}
|
||||
if result["copyright"] != "(C) Queen" {
|
||||
t.Fatalf("unexpected copyright: %q", result["copyright"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDeezerISRCSearchResultAddsCompatibilityIDs(t *testing.T) {
|
||||
result := buildDeezerISRCSearchResult(&TrackMetadata{
|
||||
SpotifyID: "deezer:3135556",
|
||||
Name: "Love Of My Life",
|
||||
Artists: "Queen",
|
||||
AlbumName: "A Night at the Opera",
|
||||
ISRC: "GBUM71029604",
|
||||
ReleaseDate: "1975-11-21",
|
||||
})
|
||||
|
||||
if result["spotify_id"] != "deezer:3135556" {
|
||||
t.Fatalf("unexpected spotify_id: %v", result["spotify_id"])
|
||||
}
|
||||
if result["id"] != "3135556" {
|
||||
t.Fatalf("unexpected id: %v", result["id"])
|
||||
}
|
||||
if result["track_id"] != "3135556" {
|
||||
t.Fatalf("unexpected track_id: %v", result["track_id"])
|
||||
}
|
||||
if result["success"] != true {
|
||||
t.Fatalf("expected success=true, got %v", result["success"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package gobackend
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBuildDownloadSuccessResponsePrefersRequestedAlbumMetadata(t *testing.T) {
|
||||
req := DownloadRequest{
|
||||
TrackName: "Bonus Track",
|
||||
ArtistName: "Artist",
|
||||
AlbumName: "Album (Deluxe)",
|
||||
AlbumArtist: "Artist",
|
||||
ReleaseDate: "2024-01-01",
|
||||
TrackNumber: 14,
|
||||
DiscNumber: 1,
|
||||
ISRC: "REQ123",
|
||||
CoverURL: "https://example.com/cover.jpg",
|
||||
Genre: "Pop",
|
||||
Label: "Label",
|
||||
Copyright: "Copyright",
|
||||
}
|
||||
|
||||
result := DownloadResult{
|
||||
Title: "Bonus Track",
|
||||
Artist: "Artist",
|
||||
Album: "Album",
|
||||
ReleaseDate: "2023-12-01",
|
||||
TrackNumber: 2,
|
||||
DiscNumber: 9,
|
||||
ISRC: "RES456",
|
||||
}
|
||||
|
||||
resp := buildDownloadSuccessResponse(
|
||||
req,
|
||||
result,
|
||||
"tidal",
|
||||
"ok",
|
||||
"/tmp/test.flac",
|
||||
false,
|
||||
)
|
||||
|
||||
if resp.Album != req.AlbumName {
|
||||
t.Fatalf("album = %q, want %q", resp.Album, req.AlbumName)
|
||||
}
|
||||
if resp.ReleaseDate != req.ReleaseDate {
|
||||
t.Fatalf("release date = %q, want %q", resp.ReleaseDate, req.ReleaseDate)
|
||||
}
|
||||
if resp.TrackNumber != req.TrackNumber {
|
||||
t.Fatalf("track number = %d, want %d", resp.TrackNumber, req.TrackNumber)
|
||||
}
|
||||
if resp.DiscNumber != req.DiscNumber {
|
||||
t.Fatalf("disc number = %d, want %d", resp.DiscNumber, req.DiscNumber)
|
||||
}
|
||||
if resp.Artist != result.Artist {
|
||||
t.Fatalf("artist = %q, want provider artist %q", resp.Artist, result.Artist)
|
||||
}
|
||||
if resp.ISRC != result.ISRC {
|
||||
t.Fatalf("isrc = %q, want provider isrc %q", resp.ISRC, result.ISRC)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreferredReleaseMetadataPrefersRequestValues(t *testing.T) {
|
||||
album, releaseDate, trackNumber, discNumber := preferredReleaseMetadata(
|
||||
DownloadRequest{
|
||||
AlbumName: "Album (Deluxe Edition)",
|
||||
ReleaseDate: "2024-01-01",
|
||||
TrackNumber: 13,
|
||||
DiscNumber: 2,
|
||||
},
|
||||
"Album",
|
||||
"2023-01-01",
|
||||
3,
|
||||
1,
|
||||
)
|
||||
|
||||
if album != "Album (Deluxe Edition)" {
|
||||
t.Fatalf("album = %q", album)
|
||||
}
|
||||
if releaseDate != "2024-01-01" {
|
||||
t.Fatalf("release date = %q", releaseDate)
|
||||
}
|
||||
if trackNumber != 13 {
|
||||
t.Fatalf("track number = %d", trackNumber)
|
||||
}
|
||||
if discNumber != 2 {
|
||||
t.Fatalf("disc number = %d", discNumber)
|
||||
}
|
||||
}
|
||||
@@ -1065,8 +1065,9 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
GoLog("[DownloadWithExtensionFallback] Metadata provider search failed (non-fatal): %v\n", searchErr)
|
||||
}
|
||||
|
||||
// Try Deezer extended metadata for genre/label if we have ISRC
|
||||
if req.ISRC != "" && (req.Genre == "" || req.Label == "") {
|
||||
// Try Deezer extended metadata if we have ISRC
|
||||
if req.ISRC != "" &&
|
||||
(req.Genre == "" || req.Label == "" || req.Copyright == "") {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
extMeta, err := GetDeezerClient().GetExtendedMetadataByISRC(ctx, req.ISRC)
|
||||
cancel()
|
||||
@@ -1077,7 +1078,10 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
if req.Label == "" && extMeta.Label != "" {
|
||||
req.Label = extMeta.Label
|
||||
}
|
||||
GoLog("[DownloadWithExtensionFallback] Extended metadata from Deezer: genre=%s, label=%s\n", req.Genre, req.Label)
|
||||
if req.Copyright == "" && extMeta.Copyright != "" {
|
||||
req.Copyright = extMeta.Copyright
|
||||
}
|
||||
GoLog("[DownloadWithExtensionFallback] Extended metadata from Deezer: genre=%s, label=%s, copyright=%s\n", req.Genre, req.Label, req.Copyright)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1249,7 +1253,8 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
GoLog("[DownloadWithExtensionFallback] Trying provider: %s\n", providerID)
|
||||
|
||||
if isBuiltInProvider(providerIDNormalized) {
|
||||
if (req.Genre == "" || req.Label == "") && req.ISRC != "" {
|
||||
if (req.Genre == "" || req.Label == "" || req.Copyright == "") &&
|
||||
req.ISRC != "" {
|
||||
GoLog("[DownloadWithExtensionFallback] Enriching extended metadata from Deezer for ISRC: %s\n", req.ISRC)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
deezerClient := GetDeezerClient()
|
||||
@@ -1264,6 +1269,10 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
req.Label = extMeta.Label
|
||||
GoLog("[DownloadWithExtensionFallback] Label from Deezer: %s\n", req.Label)
|
||||
}
|
||||
if req.Copyright == "" && extMeta.Copyright != "" {
|
||||
req.Copyright = extMeta.Copyright
|
||||
GoLog("[DownloadWithExtensionFallback] Copyright from Deezer: %s\n", req.Copyright)
|
||||
}
|
||||
} else if err != nil {
|
||||
GoLog("[DownloadWithExtensionFallback] Failed to get extended metadata from Deezer: %v\n", err)
|
||||
}
|
||||
|
||||
@@ -81,13 +81,14 @@ func SetExtensionTokens(extensionID string, accessToken, refreshToken string, ex
|
||||
}
|
||||
|
||||
type ExtensionRuntime struct {
|
||||
extensionID string
|
||||
manifest *ExtensionManifest
|
||||
settings map[string]interface{}
|
||||
httpClient *http.Client
|
||||
cookieJar http.CookieJar
|
||||
dataDir string
|
||||
vm *goja.Runtime
|
||||
extensionID string
|
||||
manifest *ExtensionManifest
|
||||
settings map[string]interface{}
|
||||
httpClient *http.Client
|
||||
downloadClient *http.Client
|
||||
cookieJar http.CookieJar
|
||||
dataDir string
|
||||
vm *goja.Runtime
|
||||
|
||||
storageMu sync.RWMutex
|
||||
storageCache map[string]interface{}
|
||||
@@ -132,13 +133,20 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
||||
storageFlushDelay: defaultStorageFlushDelay,
|
||||
}
|
||||
|
||||
runtime.httpClient = newExtensionHTTPClient(ext, jar, 30*time.Second)
|
||||
runtime.downloadClient = newExtensionHTTPClient(ext, jar, DownloadTimeout)
|
||||
|
||||
return runtime
|
||||
}
|
||||
|
||||
func newExtensionHTTPClient(ext *LoadedExtension, jar http.CookieJar, timeout time.Duration) *http.Client {
|
||||
// Extension sandbox enforces HTTPS-only domains. Do not apply global
|
||||
// allow_http scheme downgrade here, because some extension APIs (e.g.
|
||||
// spotify-web) will redirect http -> https and can end up in 301 loops.
|
||||
// We still reuse sharedTransport so insecure TLS compatibility mode remains effective.
|
||||
client := &http.Client{
|
||||
Transport: sharedTransport,
|
||||
Timeout: 30 * time.Second,
|
||||
Timeout: timeout,
|
||||
Jar: jar,
|
||||
}
|
||||
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
@@ -165,9 +173,7 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
runtime.httpClient = client
|
||||
|
||||
return runtime
|
||||
return client
|
||||
}
|
||||
|
||||
type RedirectBlockedError struct {
|
||||
|
||||
@@ -174,7 +174,12 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
|
||||
}
|
||||
|
||||
resp, err := r.httpClient.Do(req)
|
||||
client := r.downloadClient
|
||||
if client == nil {
|
||||
client = r.httpClient
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
|
||||
@@ -346,11 +346,12 @@ func calculateNextDelay(currentDelay time.Duration, config RetryConfig) time.Dur
|
||||
return min(nextDelay, config.MaxDelay)
|
||||
}
|
||||
|
||||
// Returns 60 seconds as default if header is missing or invalid
|
||||
// Returns 0 if the header is missing or invalid so callers can keep their
|
||||
// normal exponential backoff instead of stalling for an arbitrary minute.
|
||||
func getRetryAfterDuration(resp *http.Response) time.Duration {
|
||||
retryAfter := resp.Header.Get("Retry-After")
|
||||
if retryAfter == "" {
|
||||
return 60 * time.Second
|
||||
return 0
|
||||
}
|
||||
|
||||
if seconds, err := strconv.Atoi(retryAfter); err == nil {
|
||||
@@ -364,7 +365,7 @@ func getRetryAfterDuration(resp *http.Response) time.Duration {
|
||||
}
|
||||
}
|
||||
|
||||
return 60 * time.Second
|
||||
return 0
|
||||
}
|
||||
|
||||
func ReadResponseBody(resp *http.Response) ([]byte, error) {
|
||||
|
||||
+107
-64
@@ -3,6 +3,7 @@ package gobackend
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -121,12 +122,12 @@ func GetLyricsProviderOrder() []string {
|
||||
// GetAvailableLyricsProviders returns metadata about all available providers.
|
||||
func GetAvailableLyricsProviders() []map[string]interface{} {
|
||||
return []map[string]interface{}{
|
||||
{"id": LyricsProviderSpotifyAPI, "name": "Spotify Lyrics API", "has_proxy_dependency": true, "description": "Spotify-sourced synced lyrics via community API"},
|
||||
{"id": LyricsProviderSpotifyAPI, "name": "Spotify Lyrics API", "has_proxy_dependency": true, "description": "Spotify-sourced lyrics via Paxsenix"},
|
||||
{"id": LyricsProviderLRCLIB, "name": "LRCLIB", "has_proxy_dependency": false, "description": "Open-source synced lyrics database"},
|
||||
{"id": LyricsProviderNetease, "name": "Netease", "has_proxy_dependency": false, "description": "NetEase Cloud Music (good for Asian songs)"},
|
||||
{"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Largest lyrics database (multi-language)"},
|
||||
{"id": LyricsProviderAppleMusic, "name": "Apple Music", "has_proxy_dependency": true, "description": "Word-by-word synced lyrics"},
|
||||
{"id": LyricsProviderQQMusic, "name": "QQ Music", "has_proxy_dependency": true, "description": "QQ Music lyrics (good for Chinese songs)"},
|
||||
{"id": LyricsProviderNetease, "name": "Netease", "has_proxy_dependency": true, "description": "NetEase Cloud Music lyrics via Paxsenix"},
|
||||
{"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Musixmatch lyrics via Paxsenix"},
|
||||
{"id": LyricsProviderAppleMusic, "name": "Apple Music", "has_proxy_dependency": true, "description": "Apple Music synced lyrics via Paxsenix"},
|
||||
{"id": LyricsProviderQQMusic, "name": "QQ Music", "has_proxy_dependency": true, "description": "QQ Music lyrics via Paxsenix"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -431,6 +432,99 @@ func parseSpotifyRetryAfter(retryAfter string, now time.Time) time.Time {
|
||||
return now.Add(10 * time.Minute)
|
||||
}
|
||||
|
||||
func buildSpotifyLyricsResponse(lines []LyricsLine, syncType, plainLyrics string) (*LyricsResponse, error) {
|
||||
if len(lines) == 0 {
|
||||
return nil, fmt.Errorf("Spotify Lyrics API returned empty lines")
|
||||
}
|
||||
if syncType == "" {
|
||||
if len(lines) > 0 && lines[0].StartTimeMs > 0 {
|
||||
syncType = "LINE_SYNCED"
|
||||
} else {
|
||||
syncType = "UNSYNCED"
|
||||
}
|
||||
}
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: syncType,
|
||||
Instrumental: false,
|
||||
PlainLyrics: plainLyrics,
|
||||
Provider: "Spotify Lyrics API",
|
||||
Source: "Spotify Lyrics API",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func plainLyricsFromTimedLines(lines []LyricsLine) string {
|
||||
parts := make([]string, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
words := strings.TrimSpace(line.Words)
|
||||
if words == "" {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, words)
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
func parseSpotifyLyricsResponseBody(body []byte) (*LyricsResponse, error) {
|
||||
var lrcPayload string
|
||||
if err := json.Unmarshal(body, &lrcPayload); err == nil {
|
||||
trimmed := strings.TrimSpace(lrcPayload)
|
||||
if trimmed == "" {
|
||||
return nil, fmt.Errorf("Spotify Lyrics API returned empty payload")
|
||||
}
|
||||
|
||||
lines := parseSyncedLyrics(trimmed)
|
||||
if len(lines) > 0 {
|
||||
return buildSpotifyLyricsResponse(lines, "LINE_SYNCED", plainLyricsFromTimedLines(lines))
|
||||
}
|
||||
|
||||
plainLines := plainTextLyricsLines(trimmed)
|
||||
return buildSpotifyLyricsResponse(plainLines, "UNSYNCED", trimmed)
|
||||
}
|
||||
|
||||
var apiResp SpotifyLyricsAPIResponse
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse Spotify Lyrics API response: %w", err)
|
||||
}
|
||||
|
||||
if apiResp.Error {
|
||||
msg := strings.TrimSpace(apiResp.Message)
|
||||
if msg == "" {
|
||||
msg = "Spotify Lyrics API returned error"
|
||||
}
|
||||
return nil, fmt.Errorf("%s", msg)
|
||||
}
|
||||
|
||||
lines := make([]LyricsLine, 0, len(apiResp.Lines))
|
||||
for _, line := range apiResp.Lines {
|
||||
words := strings.TrimSpace(line.Words)
|
||||
if words == "" {
|
||||
continue
|
||||
}
|
||||
startMs := parseSpotifyLyricsTimeTagToMs(line.TimeTag)
|
||||
lines = append(lines, LyricsLine{
|
||||
StartTimeMs: startMs,
|
||||
Words: words,
|
||||
EndTimeMs: 0,
|
||||
})
|
||||
}
|
||||
|
||||
for i := 0; i < len(lines)-1; i++ {
|
||||
nextStart := lines[i+1].StartTimeMs
|
||||
if nextStart > lines[i].StartTimeMs {
|
||||
lines[i].EndTimeMs = nextStart
|
||||
}
|
||||
}
|
||||
if len(lines) > 0 {
|
||||
last := len(lines) - 1
|
||||
if lines[last].EndTimeMs == 0 {
|
||||
lines[last].EndTimeMs = lines[last].StartTimeMs + 5000
|
||||
}
|
||||
}
|
||||
|
||||
return buildSpotifyLyricsResponse(lines, apiResp.SyncType, plainLyricsFromTimedLines(lines))
|
||||
}
|
||||
|
||||
func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsResponse, error) {
|
||||
now := time.Now()
|
||||
if limitedUntil := getSpotifyLyricsRateLimitUntil(); limitedUntil.After(now) {
|
||||
@@ -449,7 +543,7 @@ func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsRespo
|
||||
spotifyID = parsed.ID
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("https://spotify-lyrics-api-pi.vercel.app/?trackid=%s&format=lrc", url.QueryEscape(spotifyID))
|
||||
apiURL := fmt.Sprintf("https://lyrics.paxsenix.org/spotify/lyrics?id=%s", url.QueryEscape(spotifyID))
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
@@ -462,13 +556,18 @@ func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsRespo
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read Spotify Lyrics API response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
retryUntil := parseSpotifyRetryAfter(resp.Header.Get("Retry-After"), now)
|
||||
setSpotifyLyricsRateLimitUntil(retryUntil)
|
||||
}
|
||||
var payload map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err == nil {
|
||||
if err := json.Unmarshal(bodyBytes, &payload); err == nil {
|
||||
if msg, ok := payload["message"].(string); ok && strings.TrimSpace(msg) != "" {
|
||||
return nil, fmt.Errorf("Spotify Lyrics API returned status %d: %s", resp.StatusCode, strings.TrimSpace(msg))
|
||||
}
|
||||
@@ -479,63 +578,7 @@ func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsRespo
|
||||
return nil, fmt.Errorf("Spotify Lyrics API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var apiResp SpotifyLyricsAPIResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse Spotify Lyrics API response: %w", err)
|
||||
}
|
||||
|
||||
if apiResp.Error {
|
||||
msg := strings.TrimSpace(apiResp.Message)
|
||||
if msg == "" {
|
||||
msg = "Spotify Lyrics API returned error"
|
||||
}
|
||||
return nil, fmt.Errorf("%s", msg)
|
||||
}
|
||||
|
||||
result := &LyricsResponse{
|
||||
Lines: make([]LyricsLine, 0, len(apiResp.Lines)),
|
||||
SyncType: apiResp.SyncType,
|
||||
Instrumental: false,
|
||||
PlainLyrics: "",
|
||||
Provider: "Spotify Lyrics API",
|
||||
Source: "Spotify Lyrics API",
|
||||
}
|
||||
|
||||
for _, line := range apiResp.Lines {
|
||||
words := strings.TrimSpace(line.Words)
|
||||
if words == "" {
|
||||
continue
|
||||
}
|
||||
startMs := parseSpotifyLyricsTimeTagToMs(line.TimeTag)
|
||||
result.Lines = append(result.Lines, LyricsLine{
|
||||
StartTimeMs: startMs,
|
||||
Words: words,
|
||||
EndTimeMs: 0,
|
||||
})
|
||||
}
|
||||
|
||||
if len(result.Lines) > 1 {
|
||||
for i := 0; i < len(result.Lines)-1; i++ {
|
||||
nextStart := result.Lines[i+1].StartTimeMs
|
||||
if nextStart > result.Lines[i].StartTimeMs {
|
||||
result.Lines[i].EndTimeMs = nextStart
|
||||
}
|
||||
}
|
||||
last := len(result.Lines) - 1
|
||||
if result.Lines[last].EndTimeMs == 0 {
|
||||
result.Lines[last].EndTimeMs = result.Lines[last].StartTimeMs + 5000
|
||||
}
|
||||
}
|
||||
|
||||
if len(result.Lines) == 0 {
|
||||
return nil, fmt.Errorf("Spotify Lyrics API returned empty lines")
|
||||
}
|
||||
|
||||
if result.SyncType == "" {
|
||||
result.SyncType = "LINE_SYNCED"
|
||||
}
|
||||
|
||||
return result, nil
|
||||
return parseSpotifyLyricsResponseBody(bodyBytes)
|
||||
}
|
||||
|
||||
func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec float64) *LRCLibResponse {
|
||||
|
||||
+65
-126
@@ -4,121 +4,25 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AppleMusicClient fetches lyrics from Apple Music.
|
||||
// Uses a scraped JWT token for search and a proxy for lyrics.
|
||||
// Uses Paxsenix endpoints for search and lyrics.
|
||||
type AppleMusicClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// Apple Music token manager — singleton with mutex for thread safety
|
||||
type appleTokenManager struct {
|
||||
mu sync.Mutex
|
||||
token string
|
||||
}
|
||||
|
||||
var globalAppleTokenManager = &appleTokenManager{}
|
||||
|
||||
func (m *appleTokenManager) getToken(client *http.Client) (string, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if m.token != "" {
|
||||
return m.token, nil
|
||||
}
|
||||
|
||||
// Step 1: Fetch the Apple Music beta page
|
||||
req, err := http.NewRequest("GET", "https://beta.music.apple.com", nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to fetch Apple Music page: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read Apple Music page: %w", err)
|
||||
}
|
||||
|
||||
// Step 2: Find the index JS file URL
|
||||
indexJsRegex := regexp.MustCompile(`/assets/index~[^/]+\.js`)
|
||||
match := indexJsRegex.Find(body)
|
||||
if match == nil {
|
||||
return "", fmt.Errorf("could not find index JS script URL on Apple Music page")
|
||||
}
|
||||
|
||||
indexJsURL := "https://beta.music.apple.com" + string(match)
|
||||
|
||||
// Step 3: Fetch the JS file
|
||||
jsReq, err := http.NewRequest("GET", indexJsURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create JS request: %w", err)
|
||||
}
|
||||
jsReq.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
jsResp, err := client.Do(jsReq)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to fetch Apple Music JS: %w", err)
|
||||
}
|
||||
defer jsResp.Body.Close()
|
||||
|
||||
jsBody, err := io.ReadAll(jsResp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read Apple Music JS: %w", err)
|
||||
}
|
||||
|
||||
// Step 4: Extract JWT token (starts with eyJh)
|
||||
tokenRegex := regexp.MustCompile(`eyJh[^"]*`)
|
||||
tokenMatch := tokenRegex.Find(jsBody)
|
||||
if tokenMatch == nil {
|
||||
return "", fmt.Errorf("could not find JWT token in Apple Music JS")
|
||||
}
|
||||
|
||||
m.token = string(tokenMatch)
|
||||
GoLog("[AppleMusic] Token obtained successfully (length: %d)\n", len(m.token))
|
||||
return m.token, nil
|
||||
}
|
||||
|
||||
func (m *appleTokenManager) clearToken() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.token = ""
|
||||
}
|
||||
|
||||
type appleMusicSearchResponse struct {
|
||||
Results struct {
|
||||
Songs *struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
} `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"`
|
||||
URL string `json:"url"`
|
||||
Artwork struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"artwork"`
|
||||
} `json:"attributes"`
|
||||
} `json:"songs"`
|
||||
} `json:"resources"`
|
||||
type appleMusicSearchResult struct {
|
||||
ID string `json:"id"`
|
||||
SongName string `json:"songName"`
|
||||
ArtistName string `json:"artistName"`
|
||||
AlbumName string `json:"albumName"`
|
||||
Duration int `json:"duration"`
|
||||
}
|
||||
|
||||
// PaxResponse represents the lyrics proxy response for word-by-word / line lyrics
|
||||
@@ -149,32 +53,71 @@ func NewAppleMusicClient() *AppleMusicClient {
|
||||
}
|
||||
}
|
||||
|
||||
func selectBestAppleMusicSearchResult(results []appleMusicSearchResult, trackName, artistName string, durationSec float64) *appleMusicSearchResult {
|
||||
if len(results) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
normalizedTrack := strings.ToLower(strings.TrimSpace(simplifyTrackName(trackName)))
|
||||
normalizedArtist := strings.ToLower(strings.TrimSpace(normalizeArtistName(artistName)))
|
||||
if normalizedArtist == "" {
|
||||
normalizedArtist = strings.ToLower(strings.TrimSpace(artistName))
|
||||
}
|
||||
|
||||
bestIndex := 0
|
||||
bestScore := -1
|
||||
for i := range results {
|
||||
result := &results[i]
|
||||
score := 0
|
||||
|
||||
candidateTrack := strings.ToLower(strings.TrimSpace(simplifyTrackName(result.SongName)))
|
||||
candidateArtist := strings.ToLower(strings.TrimSpace(normalizeArtistName(result.ArtistName)))
|
||||
|
||||
switch {
|
||||
case candidateTrack == normalizedTrack:
|
||||
score += 50
|
||||
case strings.Contains(candidateTrack, normalizedTrack) || strings.Contains(normalizedTrack, candidateTrack):
|
||||
score += 25
|
||||
}
|
||||
|
||||
switch {
|
||||
case candidateArtist == normalizedArtist:
|
||||
score += 60
|
||||
case strings.Contains(candidateArtist, normalizedArtist) || strings.Contains(normalizedArtist, candidateArtist):
|
||||
score += 30
|
||||
}
|
||||
|
||||
if durationSec > 0 && result.Duration > 0 {
|
||||
diff := math.Abs(float64(result.Duration)/1000.0 - durationSec)
|
||||
if diff <= durationToleranceSec {
|
||||
score += 20
|
||||
}
|
||||
}
|
||||
|
||||
if score > bestScore {
|
||||
bestScore = score
|
||||
bestIndex = i
|
||||
}
|
||||
}
|
||||
|
||||
return &results[bestIndex]
|
||||
}
|
||||
|
||||
// SearchSong searches for a song on Apple Music and returns its ID.
|
||||
func (c *AppleMusicClient) SearchSong(trackName, artistName string) (string, error) {
|
||||
func (c *AppleMusicClient) SearchSong(trackName, artistName string, durationSec float64) (string, error) {
|
||||
query := trackName + " " + artistName
|
||||
if strings.TrimSpace(query) == "" {
|
||||
return "", fmt.Errorf("empty search query")
|
||||
}
|
||||
|
||||
token, err := globalAppleTokenManager.getToken(c.httpClient)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("apple music token error: %w", err)
|
||||
}
|
||||
|
||||
encodedQuery := url.QueryEscape(query)
|
||||
searchURL := fmt.Sprintf(
|
||||
"https://amp-api.music.apple.com/v1/catalog/us/search?term=%s&types=songs&limit=5&l=en-US&platform=web&format[resources]=map&include[songs]=artists&extend=artistUrl",
|
||||
encodedQuery,
|
||||
)
|
||||
searchURL := fmt.Sprintf("https://lyrics.paxsenix.org/apple-music/search?q=%s", encodedQuery)
|
||||
|
||||
req, err := http.NewRequest("GET", searchURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create 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", getRandomUserAgent())
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
@@ -184,25 +127,21 @@ func (c *AppleMusicClient) SearchSong(trackName, artistName string) (string, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 401 {
|
||||
globalAppleTokenManager.clearToken()
|
||||
return "", fmt.Errorf("apple music token expired")
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("apple music search returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var searchResp appleMusicSearchResponse
|
||||
var searchResp []appleMusicSearchResult
|
||||
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
||||
return "", fmt.Errorf("failed to decode apple music response: %w", err)
|
||||
}
|
||||
|
||||
if searchResp.Results.Songs == nil || len(searchResp.Results.Songs.Data) == 0 {
|
||||
best := selectBestAppleMusicSearchResult(searchResp, trackName, artistName, durationSec)
|
||||
if best == nil || strings.TrimSpace(best.ID) == "" {
|
||||
return "", fmt.Errorf("no songs found on apple music")
|
||||
}
|
||||
|
||||
return searchResp.Results.Songs.Data[0].ID, nil
|
||||
return strings.TrimSpace(best.ID), nil
|
||||
}
|
||||
|
||||
// FetchLyricsByID fetches lyrics from the paxsenix proxy using Apple Music song ID.
|
||||
@@ -320,7 +259,7 @@ func (c *AppleMusicClient) FetchLyrics(
|
||||
durationSec float64,
|
||||
multiPersonWordByWord bool,
|
||||
) (*LyricsResponse, error) {
|
||||
songID, err := c.SearchSong(trackName, artistName)
|
||||
songID, err := c.SearchSong(trackName, artistName, durationSec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package gobackend
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
@@ -45,100 +47,105 @@ type musixmatchLyricsResponse struct {
|
||||
func NewMusixmatchClient() *MusixmatchClient {
|
||||
return &MusixmatchClient{
|
||||
httpClient: NewMetadataHTTPClient(15 * time.Second),
|
||||
baseURL: "http://158.180.60.95",
|
||||
baseURL: "https://lyrics.paxsenix.org/musixmatch/lyrics",
|
||||
}
|
||||
}
|
||||
|
||||
// searchAndGetLyrics searches for a song and retrieves its lyrics in one call.
|
||||
// The Musixmatch proxy returns both search result and lyrics in a single response.
|
||||
func (c *MusixmatchClient) searchAndGetLyrics(trackName, artistName string) (*musixmatchSearchResponse, error) {
|
||||
func (c *MusixmatchClient) fetchLyricsPayload(trackName, artistName string, durationSec float64, lyricsType, language string) (string, error) {
|
||||
if strings.TrimSpace(trackName) == "" || strings.TrimSpace(artistName) == "" {
|
||||
return nil, fmt.Errorf("empty track or artist name")
|
||||
return "", fmt.Errorf("empty track or artist name")
|
||||
}
|
||||
|
||||
encodedArtist := url.QueryEscape(artistName)
|
||||
encodedTrack := url.QueryEscape(trackName)
|
||||
|
||||
fullURL := fmt.Sprintf("%s/v2/full?artist=%s&track=%s", c.baseURL, encodedArtist, encodedTrack)
|
||||
params := url.Values{}
|
||||
params.Set("t", trackName)
|
||||
params.Set("a", artistName)
|
||||
params.Set("type", lyricsType)
|
||||
params.Set("format", "lrc")
|
||||
if durationSec > 0 {
|
||||
params.Set("d", fmt.Sprintf("%d", int(math.Round(durationSec))))
|
||||
}
|
||||
if strings.TrimSpace(language) != "" {
|
||||
params.Set("l", strings.ToLower(strings.TrimSpace(language)))
|
||||
}
|
||||
fullURL := c.baseURL + "?" + params.Encode()
|
||||
|
||||
req, err := http.NewRequest("GET", fullURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("musixmatch search failed: %w", err)
|
||||
return "", fmt.Errorf("musixmatch request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read musixmatch response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("musixmatch proxy returned HTTP %d", resp.StatusCode)
|
||||
trimmed := strings.TrimSpace(string(body))
|
||||
if errMsg, isErrorPayload := detectLyricsErrorPayload(trimmed); isErrorPayload {
|
||||
return "", fmt.Errorf("musixmatch proxy returned HTTP %d: %s", resp.StatusCode, errMsg)
|
||||
}
|
||||
return "", fmt.Errorf("musixmatch proxy returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result musixmatchSearchResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode musixmatch response: %w", err)
|
||||
var lrcPayload string
|
||||
if err := json.Unmarshal(body, &lrcPayload); err == nil {
|
||||
lrcPayload = strings.TrimSpace(lrcPayload)
|
||||
if lrcPayload == "" {
|
||||
return "", fmt.Errorf("empty musixmatch lyrics payload")
|
||||
}
|
||||
return lrcPayload, nil
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
trimmed := strings.TrimSpace(string(body))
|
||||
if errMsg, isErrorPayload := detectLyricsErrorPayload(trimmed); isErrorPayload {
|
||||
return "", fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
if trimmed != "" && !strings.HasPrefix(trimmed, "{") {
|
||||
return trimmed, nil
|
||||
}
|
||||
return "", fmt.Errorf("failed to decode musixmatch response")
|
||||
}
|
||||
|
||||
// FetchLyricsInLanguage retrieves lyrics from Musixmatch for a specific language code.
|
||||
func (c *MusixmatchClient) FetchLyricsInLanguage(songID int64, language string) (*LyricsResponse, error) {
|
||||
func (c *MusixmatchClient) FetchLyricsInLanguage(trackName, artistName string, durationSec float64, language string) (*LyricsResponse, error) {
|
||||
lang := strings.ToLower(strings.TrimSpace(language))
|
||||
if songID <= 0 || lang == "" {
|
||||
return nil, fmt.Errorf("invalid song id or language")
|
||||
if lang == "" {
|
||||
return nil, fmt.Errorf("invalid language")
|
||||
}
|
||||
|
||||
fullURL := fmt.Sprintf("%s/v2/full?id=%d&lang=%s", c.baseURL, songID, url.QueryEscape(lang))
|
||||
|
||||
req, err := http.NewRequest("GET", fullURL, nil)
|
||||
lrcText, err := c.fetchLyricsPayload(trackName, artistName, durationSec, "translate", lang)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("musixmatch language fetch failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("musixmatch language endpoint returned HTTP %d", resp.StatusCode)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result musixmatchSearchResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode musixmatch language response: %w", err)
|
||||
lines := parseSyncedLyrics(lrcText)
|
||||
if len(lines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: "LINE_SYNCED",
|
||||
PlainLyrics: plainLyricsFromTimedLines(lines),
|
||||
Provider: "Musixmatch",
|
||||
Source: fmt.Sprintf("Musixmatch (%s)", lang),
|
||||
}, nil
|
||||
}
|
||||
|
||||
if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" {
|
||||
lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics)
|
||||
if len(lines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: "LINE_SYNCED",
|
||||
Provider: "Musixmatch",
|
||||
Source: fmt.Sprintf("Musixmatch (%s)", lang),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" {
|
||||
lines := plainTextLyricsLines(result.UnsyncedLyrics.Lyrics)
|
||||
|
||||
if len(lines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: "UNSYNCED",
|
||||
PlainLyrics: result.UnsyncedLyrics.Lyrics,
|
||||
Provider: "Musixmatch",
|
||||
Source: fmt.Sprintf("Musixmatch (%s)", lang),
|
||||
}, nil
|
||||
}
|
||||
plainLines := plainTextLyricsLines(lrcText)
|
||||
if len(plainLines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: plainLines,
|
||||
SyncType: "UNSYNCED",
|
||||
PlainLyrics: lrcText,
|
||||
Provider: "Musixmatch",
|
||||
Source: fmt.Sprintf("Musixmatch (%s)", lang),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no lyrics found on musixmatch for language %s", lang)
|
||||
@@ -146,43 +153,39 @@ func (c *MusixmatchClient) FetchLyricsInLanguage(songID int64, language string)
|
||||
|
||||
// FetchLyrics searches Musixmatch and returns parsed LyricsResponse.
|
||||
func (c *MusixmatchClient) FetchLyrics(trackName, artistName string, durationSec float64, preferredLanguage string) (*LyricsResponse, error) {
|
||||
result, err := c.searchAndGetLyrics(trackName, artistName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if preferred := strings.ToLower(strings.TrimSpace(preferredLanguage)); preferred != "" && result.ID > 0 {
|
||||
localized, localizedErr := c.FetchLyricsInLanguage(result.ID, preferred)
|
||||
if preferred := strings.ToLower(strings.TrimSpace(preferredLanguage)); preferred != "" {
|
||||
localized, localizedErr := c.FetchLyricsInLanguage(trackName, artistName, durationSec, preferred)
|
||||
if localizedErr == nil {
|
||||
return localized, nil
|
||||
}
|
||||
GoLog("[Musixmatch] Language override '%s' failed: %v\n", preferred, localizedErr)
|
||||
}
|
||||
|
||||
if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" {
|
||||
lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics)
|
||||
if len(lines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: "LINE_SYNCED",
|
||||
Provider: "Musixmatch",
|
||||
Source: "Musixmatch",
|
||||
}, nil
|
||||
}
|
||||
lrcText, err := c.fetchLyricsPayload(trackName, artistName, durationSec, "word", "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" {
|
||||
lines := plainTextLyricsLines(result.UnsyncedLyrics.Lyrics)
|
||||
lines := parseSyncedLyrics(lrcText)
|
||||
if len(lines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: "LINE_SYNCED",
|
||||
PlainLyrics: plainLyricsFromTimedLines(lines),
|
||||
Provider: "Musixmatch",
|
||||
Source: "Musixmatch",
|
||||
}, nil
|
||||
}
|
||||
|
||||
if len(lines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: "UNSYNCED",
|
||||
PlainLyrics: result.UnsyncedLyrics.Lyrics,
|
||||
Provider: "Musixmatch",
|
||||
Source: "Musixmatch",
|
||||
}, nil
|
||||
}
|
||||
plainLines := plainTextLyricsLines(lrcText)
|
||||
if len(plainLines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: plainLines,
|
||||
SyncType: "UNSYNCED",
|
||||
PlainLyrics: lrcText,
|
||||
Provider: "Musixmatch",
|
||||
Source: "Musixmatch",
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no lyrics found on musixmatch")
|
||||
|
||||
@@ -9,8 +9,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// NeteaseClient fetches lyrics from NetEase Cloud Music (music.163.com).
|
||||
// This is a direct public API — no proxy dependency.
|
||||
// NeteaseClient fetches lyrics through Paxsenix's NetEase endpoints.
|
||||
type NeteaseClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
@@ -59,12 +58,9 @@ func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error)
|
||||
return 0, fmt.Errorf("empty search query")
|
||||
}
|
||||
|
||||
searchURL := "http://music.163.com/api/search/pc"
|
||||
searchURL := "https://lyrics.paxsenix.org/netease/search"
|
||||
params := url.Values{}
|
||||
params.Set("s", query)
|
||||
params.Set("type", "1")
|
||||
params.Set("limit", "1")
|
||||
params.Set("offset", "0")
|
||||
params.Set("q", query)
|
||||
|
||||
fullURL := searchURL + "?" + params.Encode()
|
||||
|
||||
@@ -102,12 +98,9 @@ func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error)
|
||||
|
||||
// FetchLyricsByID fetches synced lyrics for a given Netease song ID.
|
||||
func (c *NeteaseClient) FetchLyricsByID(songID int64, includeTranslation, includeRomanization bool) (string, error) {
|
||||
lyricsURL := "http://music.163.com/api/song/lyric"
|
||||
lyricsURL := "https://lyrics.paxsenix.org/netease/lyrics"
|
||||
params := url.Values{}
|
||||
params.Set("id", fmt.Sprintf("%d", songID))
|
||||
params.Set("lv", "1")
|
||||
params.Set("tv", "1")
|
||||
params.Set("rv", "1")
|
||||
|
||||
fullURL := lyricsURL + "?" + params.Encode()
|
||||
|
||||
|
||||
@@ -1,45 +1,31 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// QQMusicClient fetches lyrics from QQ Music.
|
||||
// Search uses public QQ Music API, lyrics use the paxsenix proxy.
|
||||
// Uses Paxsenix metadata lookup for lyrics.
|
||||
type QQMusicClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
type qqMusicSearchResponse struct {
|
||||
Data struct {
|
||||
Song struct {
|
||||
List []struct {
|
||||
Title string `json:"title"`
|
||||
Singer []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"singer"`
|
||||
Album struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"album"`
|
||||
ID int64 `json:"id"`
|
||||
} `json:"list"`
|
||||
} `json:"song"`
|
||||
} `json:"data"`
|
||||
type qqLyricsMetadataRequest struct {
|
||||
Artist []string `json:"artist"`
|
||||
Album string `json:"album,omitempty"`
|
||||
SongID int64 `json:"songid,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Duration int64 `json:"duration,omitempty"`
|
||||
}
|
||||
|
||||
// QQ Music lyrics request payload for paxsenix proxy
|
||||
type qqLyricsPayload struct {
|
||||
Artist []string `json:"artist"`
|
||||
Album string `json:"album"`
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
type qqLyricsMetadataResponse struct {
|
||||
Lyrics []paxLyrics `json:"lyrics"`
|
||||
}
|
||||
|
||||
func NewQQMusicClient() *QQMusicClient {
|
||||
@@ -48,79 +34,29 @@ func NewQQMusicClient() *QQMusicClient {
|
||||
}
|
||||
}
|
||||
|
||||
// searchSong searches QQ Music and returns the song info needed for lyrics fetch.
|
||||
func (c *QQMusicClient) searchSong(trackName, artistName string) (*qqLyricsPayload, error) {
|
||||
query := trackName + " " + artistName
|
||||
if strings.TrimSpace(query) == "" {
|
||||
return nil, fmt.Errorf("empty search query")
|
||||
// fetchLyricsByMetadata asks Paxsenix to resolve and return QQ lyrics using track metadata.
|
||||
func (c *QQMusicClient) fetchLyricsByMetadata(trackName, artistName string, durationSec float64) (string, error) {
|
||||
payload := qqLyricsMetadataRequest{
|
||||
Artist: []string{artistName},
|
||||
Title: trackName,
|
||||
}
|
||||
if durationSec > 0 {
|
||||
payload.Duration = int64(math.Round(durationSec))
|
||||
}
|
||||
|
||||
searchURL := "https://c.y.qq.com/soso/fcgi-bin/client_search_cp"
|
||||
params := url.Values{}
|
||||
params.Set("format", "json")
|
||||
params.Set("inCharset", "utf8")
|
||||
params.Set("outCharset", "utf8")
|
||||
params.Set("platform", "yqq.json")
|
||||
params.Set("new_json", "1")
|
||||
params.Set("w", query)
|
||||
|
||||
fullURL := searchURL + "?" + params.Encode()
|
||||
|
||||
req, err := http.NewRequest("GET", fullURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("qqmusic search failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("qqmusic search returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var searchResp qqMusicSearchResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode qqmusic response: %w", err)
|
||||
}
|
||||
|
||||
if len(searchResp.Data.Song.List) == 0 {
|
||||
return nil, fmt.Errorf("no songs found on qqmusic")
|
||||
}
|
||||
|
||||
song := searchResp.Data.Song.List[0]
|
||||
|
||||
var artists []string
|
||||
for _, singer := range song.Singer {
|
||||
artists = append(artists, singer.Name)
|
||||
}
|
||||
|
||||
return &qqLyricsPayload{
|
||||
Artist: artists,
|
||||
Album: song.Album.Name,
|
||||
ID: song.ID,
|
||||
Title: song.Title,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// fetchLyricsByPayload fetches lyrics from the paxsenix proxy using QQ Music song info.
|
||||
func (c *QQMusicClient) fetchLyricsByPayload(payload *qqLyricsPayload) (string, error) {
|
||||
lyricsURL := "https://paxsenix.alwaysdata.net/getQQLyrics.php"
|
||||
lyricsURL := "https://lyrics.paxsenix.org/qq/lyrics-metadata"
|
||||
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal payload: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", lyricsURL, bytes.NewReader(payloadBytes))
|
||||
req, err := http.NewRequest("POST", lyricsURL, strings.NewReader(string(payloadBytes)))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
@@ -146,6 +82,17 @@ func (c *QQMusicClient) fetchLyricsByPayload(payload *qqLyricsPayload) (string,
|
||||
return bodyStr, nil
|
||||
}
|
||||
|
||||
func formatQQLyricsMetadataToLRC(rawJSON string, multiPersonWordByWord bool) (string, error) {
|
||||
var response qqLyricsMetadataResponse
|
||||
if err := json.Unmarshal([]byte(rawJSON), &response); err != nil {
|
||||
return "", fmt.Errorf("failed to parse qq metadata lyrics response")
|
||||
}
|
||||
if len(response.Lyrics) == 0 {
|
||||
return "", fmt.Errorf("qq metadata lyrics response was empty")
|
||||
}
|
||||
return formatPaxContent("Syllable", response.Lyrics, multiPersonWordByWord), nil
|
||||
}
|
||||
|
||||
// FetchLyrics searches QQ Music and returns parsed LyricsResponse.
|
||||
func (c *QQMusicClient) FetchLyrics(
|
||||
trackName,
|
||||
@@ -153,12 +100,7 @@ func (c *QQMusicClient) FetchLyrics(
|
||||
durationSec float64,
|
||||
multiPersonWordByWord bool,
|
||||
) (*LyricsResponse, error) {
|
||||
payload, err := c.searchSong(trackName, artistName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rawLyrics, err := c.fetchLyricsByPayload(payload)
|
||||
rawLyrics, err := c.fetchLyricsByMetadata(trackName, artistName, durationSec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -166,11 +108,13 @@ func (c *QQMusicClient) FetchLyrics(
|
||||
return nil, fmt.Errorf("qqmusic proxy returned non-lyric payload: %s", errMsg)
|
||||
}
|
||||
|
||||
// Try to parse as pax format (word-by-word or line)
|
||||
lrcText, err := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord)
|
||||
lrcText, err := formatQQLyricsMetadataToLRC(rawLyrics, multiPersonWordByWord)
|
||||
if err != nil {
|
||||
// If pax parsing fails, try to use as direct LRC text
|
||||
lrcText = rawLyrics
|
||||
if fallback, fallbackErr := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord); fallbackErr == nil {
|
||||
lrcText = fallback
|
||||
} else {
|
||||
lrcText = rawLyrics
|
||||
}
|
||||
}
|
||||
|
||||
lines := parseSyncedLyrics(lrcText)
|
||||
|
||||
+174
-6
@@ -552,6 +552,14 @@ func ExtractLyrics(filePath string) (string, error) {
|
||||
return extractLyricsFromSidecarLRC(filePath)
|
||||
}
|
||||
|
||||
if strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".aac") {
|
||||
lyrics, err := extractLyricsFromM4A(filePath)
|
||||
if err == nil && strings.TrimSpace(lyrics) != "" {
|
||||
return lyrics, nil
|
||||
}
|
||||
return extractLyricsFromSidecarLRC(filePath)
|
||||
}
|
||||
|
||||
if strings.HasSuffix(lower, ".mp3") {
|
||||
meta, err := ReadID3Tags(filePath)
|
||||
if err == nil && meta != nil {
|
||||
@@ -581,6 +589,153 @@ func ExtractLyrics(filePath string) (string, error) {
|
||||
return extractLyricsFromSidecarLRC(filePath)
|
||||
}
|
||||
|
||||
func extractLyricsFromM4A(filePath string) (string, error) {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
fi, err := f.Stat()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
fileSize := fi.Size()
|
||||
|
||||
moov, found, err := findAtomInRange(f, 0, fileSize, "moov", fileSize)
|
||||
if err != nil || !found {
|
||||
return "", fmt.Errorf("moov not found")
|
||||
}
|
||||
|
||||
bodyStart := moov.offset + moov.headerSize
|
||||
bodySize := moov.size - moov.headerSize
|
||||
|
||||
udta, found, err := findAtomInRange(f, bodyStart, bodySize, "udta", fileSize)
|
||||
if err != nil || !found {
|
||||
return "", fmt.Errorf("udta not found")
|
||||
}
|
||||
|
||||
bodyStart = udta.offset + udta.headerSize
|
||||
bodySize = udta.size - udta.headerSize
|
||||
|
||||
meta, found, err := findAtomInRange(f, bodyStart, bodySize, "meta", fileSize)
|
||||
if err != nil || !found {
|
||||
return "", fmt.Errorf("meta not found")
|
||||
}
|
||||
|
||||
// meta atom has 4-byte version/flags after the header
|
||||
bodyStart = meta.offset + meta.headerSize + 4
|
||||
bodySize = meta.size - meta.headerSize - 4
|
||||
|
||||
ilst, found, err := findAtomInRange(f, bodyStart, bodySize, "ilst", fileSize)
|
||||
if err != nil || !found {
|
||||
return "", fmt.Errorf("ilst not found")
|
||||
}
|
||||
|
||||
bodyStart = ilst.offset + ilst.headerSize
|
||||
bodySize = ilst.size - ilst.headerSize
|
||||
|
||||
lyr, found, err := findAtomInRange(f, bodyStart, bodySize, "\xa9lyr", fileSize)
|
||||
if err != nil || !found {
|
||||
return "", fmt.Errorf("lyrics atom not found")
|
||||
}
|
||||
|
||||
dataStart := lyr.offset + lyr.headerSize
|
||||
dataSize := lyr.size - lyr.headerSize
|
||||
|
||||
dataAtom, found, err := findAtomInRange(f, dataStart, dataSize, "data", fileSize)
|
||||
if err != nil || !found {
|
||||
return "", fmt.Errorf("data atom not found in lyrics")
|
||||
}
|
||||
|
||||
// data atom: 8 bytes header + 4 bytes type indicator + 4 bytes locale = skip 8
|
||||
textStart := dataAtom.offset + dataAtom.headerSize + 8
|
||||
textLen := dataAtom.size - dataAtom.headerSize - 8
|
||||
if textLen <= 0 {
|
||||
return "", fmt.Errorf("empty lyrics")
|
||||
}
|
||||
|
||||
buf := make([]byte, textLen)
|
||||
if _, err := f.ReadAt(buf, textStart); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(buf), nil
|
||||
}
|
||||
|
||||
func extractCoverFromM4A(filePath string) ([]byte, error) {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
fi, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fileSize := fi.Size()
|
||||
|
||||
moov, found, err := findAtomInRange(f, 0, fileSize, "moov", fileSize)
|
||||
if err != nil || !found {
|
||||
return nil, fmt.Errorf("moov not found")
|
||||
}
|
||||
|
||||
bodyStart := moov.offset + moov.headerSize
|
||||
bodySize := moov.size - moov.headerSize
|
||||
|
||||
udta, found, err := findAtomInRange(f, bodyStart, bodySize, "udta", fileSize)
|
||||
if err != nil || !found {
|
||||
return nil, fmt.Errorf("udta not found")
|
||||
}
|
||||
|
||||
bodyStart = udta.offset + udta.headerSize
|
||||
bodySize = udta.size - udta.headerSize
|
||||
|
||||
meta, found, err := findAtomInRange(f, bodyStart, bodySize, "meta", fileSize)
|
||||
if err != nil || !found {
|
||||
return nil, fmt.Errorf("meta not found")
|
||||
}
|
||||
|
||||
bodyStart = meta.offset + meta.headerSize + 4
|
||||
bodySize = meta.size - meta.headerSize - 4
|
||||
|
||||
ilst, found, err := findAtomInRange(f, bodyStart, bodySize, "ilst", fileSize)
|
||||
if err != nil || !found {
|
||||
return nil, fmt.Errorf("ilst not found")
|
||||
}
|
||||
|
||||
bodyStart = ilst.offset + ilst.headerSize
|
||||
bodySize = ilst.size - ilst.headerSize
|
||||
|
||||
covr, found, err := findAtomInRange(f, bodyStart, bodySize, "covr", fileSize)
|
||||
if err != nil || !found {
|
||||
return nil, fmt.Errorf("cover atom not found")
|
||||
}
|
||||
|
||||
dataStart := covr.offset + covr.headerSize
|
||||
dataSize := covr.size - covr.headerSize
|
||||
|
||||
dataAtom, found, err := findAtomInRange(f, dataStart, dataSize, "data", fileSize)
|
||||
if err != nil || !found {
|
||||
return nil, fmt.Errorf("data atom not found in cover")
|
||||
}
|
||||
|
||||
// data atom: header + 4 bytes type indicator + 4 bytes locale
|
||||
imgStart := dataAtom.offset + dataAtom.headerSize + 8
|
||||
imgLen := dataAtom.size - dataAtom.headerSize - 8
|
||||
if imgLen <= 0 {
|
||||
return nil, fmt.Errorf("empty cover data")
|
||||
}
|
||||
|
||||
buf := make([]byte, imgLen)
|
||||
if _, err := f.ReadAt(buf, imgStart); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
func extractLyricsFromSidecarLRC(filePath string) (string, error) {
|
||||
ext := filepath.Ext(filePath)
|
||||
base := strings.TrimSuffix(filePath, ext)
|
||||
@@ -743,15 +898,28 @@ func GetM4AQuality(filePath string) (AudioQuality, error) {
|
||||
return AudioQuality{}, err
|
||||
}
|
||||
|
||||
buf := make([]byte, 24)
|
||||
buf := make([]byte, 32)
|
||||
if _, err := f.ReadAt(buf, sampleOffset); err != nil {
|
||||
return AudioQuality{}, fmt.Errorf("failed to read audio sample entry: %w", err)
|
||||
}
|
||||
|
||||
sampleRate := int(buf[22])<<8 | int(buf[23])
|
||||
bitDepth := 16
|
||||
if atomType == "alac" {
|
||||
bitDepth = 24
|
||||
// AudioSampleEntry layout from the box type field:
|
||||
// [0:4] type ("mp4a"/"alac")
|
||||
// [4:10] SampleEntry.reserved
|
||||
// [10:12] data_reference_index
|
||||
// [12:20] reserved[8]
|
||||
// [20:22] channelcount
|
||||
// [22:24] samplesize (bit depth)
|
||||
// [24:26] pre_defined
|
||||
// [26:28] reserved
|
||||
// [28:32] samplerate (16.16 fixed-point)
|
||||
sampleRate := int(buf[28])<<8 | int(buf[29])
|
||||
bitDepth := int(buf[22])<<8 | int(buf[23])
|
||||
if bitDepth <= 0 {
|
||||
bitDepth = 16
|
||||
if atomType == "alac" {
|
||||
bitDepth = 24
|
||||
}
|
||||
}
|
||||
|
||||
return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate}, nil
|
||||
@@ -874,7 +1042,7 @@ func findAudioSampleEntry(f *os.File, start, end, fileSize int64) (int64, string
|
||||
|
||||
if bestIdx >= 0 {
|
||||
absolute := readPos - int64(len(tail)) + int64(bestIdx)
|
||||
if absolute+24 > fileSize {
|
||||
if absolute+32 > fileSize {
|
||||
return 0, "", fmt.Errorf("audio info not found in M4A file")
|
||||
}
|
||||
return absolute, bestType, nil
|
||||
|
||||
+37
-14
@@ -49,11 +49,13 @@ const (
|
||||
qobuzArtistGetBaseURL = "https://www.qobuz.com/api.json/0.2/artist/get?artist_id="
|
||||
qobuzPlaylistGetBaseURL = "https://www.qobuz.com/api.json/0.2/playlist/get?playlist_id="
|
||||
qobuzStoreSearchBaseURL = "https://www.qobuz.com/us-en/search/tracks/"
|
||||
qobuzTrackOpenBaseURL = "https://open.qobuz.com/track/"
|
||||
qobuzTrackPlayBaseURL = "https://play.qobuz.com/track/"
|
||||
qobuzStoreBaseURL = "https://www.qobuz.com/us-en"
|
||||
qobuzDownloadAPIURL = "https://www.musicdl.me/api/qobuz/download"
|
||||
qobuzDownloadAPIURL = "https://dl.musicdl.me/qobuz/download"
|
||||
qobuzDabMusicAPIURL = "https://dabmusic.xyz/api/stream?trackId="
|
||||
qobuzDeebAPIURL = "https://dab.yeet.su/api/stream?trackId="
|
||||
qobuzAfkarAPIURL = "https://qbz.afkarxyz.qzz.io/api/track/"
|
||||
qobuzSquidAPIURL = "https://qobuz.squid.wtf/api/download-music?country=US&track_id="
|
||||
qobuzDebugKeyXORMask = byte(0x5A)
|
||||
)
|
||||
@@ -1019,6 +1021,10 @@ func (q *QobuzDownloader) GetArtistMetadata(resourceID string) (*ArtistResponseP
|
||||
func (q *QobuzDownloader) GetAvailableAPIs() []string {
|
||||
return []string{
|
||||
qobuzDownloadAPIURL,
|
||||
qobuzDabMusicAPIURL,
|
||||
qobuzDeebAPIURL,
|
||||
qobuzAfkarAPIURL,
|
||||
qobuzSquidAPIURL,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1039,6 +1045,8 @@ func (q *QobuzDownloader) GetAvailableProviders() []qobuzAPIProvider {
|
||||
{Name: "dabmusic", URL: qobuzDabMusicAPIURL, Kind: qobuzAPIKindStandard},
|
||||
// "deeb" is mapped from the legacy reference fallback endpoint.
|
||||
{Name: "deeb", URL: qobuzDeebAPIURL, Kind: qobuzAPIKindStandard},
|
||||
// "qbz" comes from the desktop reference app and uses /api/track/{id}?quality=...
|
||||
{Name: "qbz", URL: qobuzAfkarAPIURL, Kind: qobuzAPIKindStandard},
|
||||
{Name: "squid", URL: qobuzSquidAPIURL, Kind: qobuzAPIKindStandard},
|
||||
}
|
||||
}
|
||||
@@ -1624,19 +1632,23 @@ func fetchQobuzURLWithRetry(provider qobuzAPIProvider, trackID int64, quality st
|
||||
return fetchQobuzURLSingleAttempt(provider, trackID, quality, timeout, "")
|
||||
}
|
||||
|
||||
func buildQobuzMusicDLPayload(trackID int64, quality string) ([]byte, error) {
|
||||
requestQuality := mapQobuzQualityCodeToAPI(quality)
|
||||
payload := map[string]any{
|
||||
"quality": requestQuality,
|
||||
"upload_to_r2": false,
|
||||
"url": fmt.Sprintf("%s%d", qobuzTrackOpenBaseURL, trackID),
|
||||
}
|
||||
return json.Marshal(payload)
|
||||
}
|
||||
|
||||
func fetchQobuzURLSingleAttempt(provider qobuzAPIProvider, trackID int64, quality string, timeout time.Duration, country string) (qobuzDownloadInfo, error) {
|
||||
var lastErr error
|
||||
retryDelay := qobuzRetryDelay
|
||||
var payloadBytes []byte
|
||||
if provider.Kind == qobuzAPIKindMusicDL {
|
||||
requestQuality := mapQobuzQualityCodeToAPI(quality)
|
||||
payload := map[string]any{
|
||||
"quality": requestQuality,
|
||||
"upload_to_r2": false,
|
||||
"url": fmt.Sprintf("%s%d", qobuzTrackPlayBaseURL, trackID),
|
||||
}
|
||||
var err error
|
||||
payloadBytes, err = json.Marshal(payload)
|
||||
payloadBytes, err = buildQobuzMusicDLPayload(trackID, quality)
|
||||
if err != nil {
|
||||
return qobuzDownloadInfo{}, fmt.Errorf("failed to encode qobuz request: %w", err)
|
||||
}
|
||||
@@ -1681,7 +1693,6 @@ func fetchQobuzURLSingleAttempt(provider qobuzAPIProvider, trackID int64, qualit
|
||||
}
|
||||
if provider.Kind == qobuzAPIKindMusicDL {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Debug-Key", getQobuzDebugKey())
|
||||
}
|
||||
|
||||
resp, err := DoRequestWithUserAgent(client, req)
|
||||
@@ -2156,6 +2167,10 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
if req.AlbumName != "" {
|
||||
albumName = req.AlbumName
|
||||
}
|
||||
releaseDate := track.Album.ReleaseDate
|
||||
if req.ReleaseDate != "" {
|
||||
releaseDate = req.ReleaseDate
|
||||
}
|
||||
|
||||
actualTrackNumber := req.TrackNumber
|
||||
if actualTrackNumber == 0 {
|
||||
@@ -2167,7 +2182,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
Artist: track.Performer.Name,
|
||||
Album: albumName,
|
||||
AlbumArtist: req.AlbumArtist,
|
||||
Date: track.Album.ReleaseDate,
|
||||
Date: releaseDate,
|
||||
TrackNumber: actualTrackNumber,
|
||||
TotalTracks: req.TotalTracks,
|
||||
DiscNumber: req.DiscNumber,
|
||||
@@ -2231,16 +2246,24 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
lyricsLRC = parallelResult.LyricsLRC
|
||||
}
|
||||
|
||||
resultAlbum, resultReleaseDate, resultTrackNumber, resultDiscNumber := preferredReleaseMetadata(
|
||||
req,
|
||||
track.Album.Title,
|
||||
track.Album.ReleaseDate,
|
||||
actualTrackNumber,
|
||||
req.DiscNumber,
|
||||
)
|
||||
|
||||
return QobuzDownloadResult{
|
||||
FilePath: outputPath,
|
||||
BitDepth: actualBitDepth,
|
||||
SampleRate: actualSampleRate,
|
||||
Title: track.Title,
|
||||
Artist: track.Performer.Name,
|
||||
Album: track.Album.Title,
|
||||
ReleaseDate: track.Album.ReleaseDate,
|
||||
TrackNumber: actualTrackNumber,
|
||||
DiscNumber: req.DiscNumber,
|
||||
Album: resultAlbum,
|
||||
ReleaseDate: resultReleaseDate,
|
||||
TrackNumber: resultTrackNumber,
|
||||
DiscNumber: resultDiscNumber,
|
||||
ISRC: track.ISRC,
|
||||
LyricsLRC: lyricsLRC,
|
||||
}, nil
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package gobackend
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseQobuzURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
@@ -195,6 +198,28 @@ func TestGetQobuzDebugKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildQobuzMusicDLPayloadUsesOpenTrackURL(t *testing.T) {
|
||||
payloadBytes, err := buildQobuzMusicDLPayload(374610875, "7")
|
||||
if err != nil {
|
||||
t.Fatalf("buildQobuzMusicDLPayload returned error: %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(payloadBytes, &payload); err != nil {
|
||||
t.Fatalf("payload is not valid JSON: %v", err)
|
||||
}
|
||||
|
||||
if got := payload["url"]; got != "https://open.qobuz.com/track/374610875" {
|
||||
t.Fatalf("payload url = %v, want open.qobuz.com track URL", got)
|
||||
}
|
||||
if got := payload["quality"]; got != "hi-res" {
|
||||
t.Fatalf("payload quality = %v, want hi-res", got)
|
||||
}
|
||||
if got := payload["upload_to_r2"]; got != false {
|
||||
t.Fatalf("payload upload_to_r2 = %v, want false", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractQobuzAlbumIDsFromArtistHTML(t *testing.T) {
|
||||
body := []byte(`
|
||||
<button data-itemtype="album" data-itemId="0886446451985"></button>
|
||||
@@ -213,14 +238,16 @@ func TestExtractQobuzAlbumIDsFromArtistHTML(t *testing.T) {
|
||||
|
||||
func TestQobuzAvailableProviders(t *testing.T) {
|
||||
providers := NewQobuzDownloader().GetAvailableProviders()
|
||||
if len(providers) != 3 {
|
||||
t.Fatalf("expected 3 Qobuz providers, got %d", len(providers))
|
||||
if len(providers) != 5 {
|
||||
t.Fatalf("expected 5 Qobuz providers, got %d", len(providers))
|
||||
}
|
||||
|
||||
want := map[string]string{
|
||||
"musicdl": qobuzAPIKindMusicDL,
|
||||
"dabmusic": qobuzAPIKindStandard,
|
||||
"deeb": qobuzAPIKindStandard,
|
||||
"qbz": qobuzAPIKindStandard,
|
||||
"squid": qobuzAPIKindStandard,
|
||||
}
|
||||
|
||||
for _, provider := range providers {
|
||||
|
||||
+144
-43
@@ -1,6 +1,7 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -14,6 +15,10 @@ type SongLinkClient struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
type songLinkPlatformLink struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type TrackAvailability struct {
|
||||
SpotifyID string `json:"spotify_id"`
|
||||
Tidal bool `json:"tidal"`
|
||||
@@ -43,6 +48,7 @@ var (
|
||||
songLinkCheckAvailabilityFromDeezer = func(s *SongLinkClient, deezerTrackID string) (*TrackAvailability, error) {
|
||||
return s.CheckAvailabilityFromDeezer(deezerTrackID)
|
||||
}
|
||||
songLinkRetryConfig = DefaultRetryConfig
|
||||
)
|
||||
|
||||
func NewSongLinkClient() *SongLinkClient {
|
||||
@@ -130,7 +136,14 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) checkTrackAvailabilityFromSpotify(spotifyTrackID string) (*TrackAvailability, error) {
|
||||
songLinkRateLimiter.WaitForSlot()
|
||||
availability, pageErr := s.checkTrackAvailabilityFromSpotifyPage(spotifyTrackID)
|
||||
if pageErr == nil {
|
||||
return availability, nil
|
||||
}
|
||||
|
||||
if !songLinkRateLimiter.TryAcquire() {
|
||||
return nil, fmt.Errorf("song.link page lookup failed: %w (SongLink local rate limit exceeded)", pageErr)
|
||||
}
|
||||
|
||||
spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
|
||||
apiURL := buildSongLinkURLFromTarget(spotifyURL, "")
|
||||
@@ -140,10 +153,10 @@ func (s *SongLinkClient) checkTrackAvailabilityFromSpotify(spotifyTrackID string
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
retryConfig := DefaultRetryConfig()
|
||||
retryConfig := songLinkRetryConfig()
|
||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check availability: %w", err)
|
||||
return nil, fmt.Errorf("song.link page lookup failed: %w; SongLink API lookup failed: %w", pageErr, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
@@ -154,10 +167,10 @@ func (s *SongLinkClient) checkTrackAvailabilityFromSpotify(spotifyTrackID string
|
||||
return nil, fmt.Errorf("track not found on any streaming platform")
|
||||
}
|
||||
if resp.StatusCode == 429 {
|
||||
return nil, fmt.Errorf("SongLink rate limit exceeded")
|
||||
return nil, fmt.Errorf("song.link page lookup failed: %w; SongLink API rate limit exceeded", pageErr)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("SongLink API returned status %d", resp.StatusCode)
|
||||
return nil, fmt.Errorf("song.link page lookup failed: %w; SongLink API returned status %d", pageErr, resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := ReadResponseBody(resp)
|
||||
@@ -166,59 +179,102 @@ func (s *SongLinkClient) checkTrackAvailabilityFromSpotify(spotifyTrackID string
|
||||
}
|
||||
|
||||
var songLinkResp struct {
|
||||
LinksByPlatform map[string]struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"linksByPlatform"`
|
||||
LinksByPlatform map[string]songLinkPlatformLink `json:"linksByPlatform"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
availability := &TrackAvailability{
|
||||
SpotifyID: spotifyTrackID,
|
||||
LogWarn("SongLink", "Spotify %s resolved via SongLink API after song.link page failure: %v", spotifyTrackID, pageErr)
|
||||
return buildTrackAvailabilityFromSongLinkLinks(spotifyTrackID, songLinkResp.LinksByPlatform), nil
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) checkTrackAvailabilityFromSpotifyPage(spotifyTrackID string) (*TrackAvailability, error) {
|
||||
pageURL := fmt.Sprintf("https://song.link/s/%s", spotifyTrackID)
|
||||
req, err := http.NewRequest("GET", pageURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create song.link page request: %w", err)
|
||||
}
|
||||
|
||||
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||
availability.Tidal = true
|
||||
availability.TidalURL = tidalLink.URL
|
||||
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml")
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch song.link page: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 404 {
|
||||
return nil, fmt.Errorf("track not found on song.link page")
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("song.link page returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||
availability.Amazon = true
|
||||
availability.AmazonURL = amazonLink.URL
|
||||
body, err := ReadResponseBody(resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read song.link page: %w", err)
|
||||
}
|
||||
|
||||
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||
availability.Deezer = true
|
||||
availability.DeezerURL = deezerLink.URL
|
||||
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
||||
nextDataJSON, err := extractSongLinkNextDataJSON(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
|
||||
availability.Qobuz = true
|
||||
availability.QobuzURL = qobuzLink.URL
|
||||
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
|
||||
var pageData struct {
|
||||
Props struct {
|
||||
PageProps struct {
|
||||
PageData struct {
|
||||
Sections []struct {
|
||||
Links []struct {
|
||||
Platform string `json:"platform"`
|
||||
URL string `json:"url"`
|
||||
Show bool `json:"show"`
|
||||
} `json:"links"`
|
||||
} `json:"sections"`
|
||||
} `json:"pageData"`
|
||||
} `json:"pageProps"`
|
||||
} `json:"props"`
|
||||
}
|
||||
if err := json.Unmarshal(nextDataJSON, &pageData); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode song.link page data: %w", err)
|
||||
}
|
||||
|
||||
// Prefer youtubeMusic URLs — they bypass Cobalt login requirements
|
||||
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
|
||||
availability.YouTube = true
|
||||
availability.YouTubeURL = ytMusicLink.URL
|
||||
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
|
||||
}
|
||||
|
||||
// Fallback to regular youtube if youtubeMusic not available
|
||||
if !availability.YouTube {
|
||||
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
|
||||
availability.YouTube = true
|
||||
availability.YouTubeURL = youtubeLink.URL
|
||||
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
|
||||
linksByPlatform := make(map[string]songLinkPlatformLink)
|
||||
for _, section := range pageData.Props.PageProps.PageData.Sections {
|
||||
for _, link := range section.Links {
|
||||
if !link.Show || strings.TrimSpace(link.URL) == "" {
|
||||
continue
|
||||
}
|
||||
linksByPlatform[link.Platform] = songLinkPlatformLink{URL: link.URL}
|
||||
}
|
||||
}
|
||||
|
||||
return availability, nil
|
||||
if len(linksByPlatform) == 0 {
|
||||
return nil, fmt.Errorf("song.link page contained no usable platform links")
|
||||
}
|
||||
|
||||
return buildTrackAvailabilityFromSongLinkLinks(spotifyTrackID, linksByPlatform), nil
|
||||
}
|
||||
|
||||
func extractSongLinkNextDataJSON(body []byte) ([]byte, error) {
|
||||
const startMarker = `<script id="__NEXT_DATA__" type="application/json">`
|
||||
const endMarker = `</script>`
|
||||
|
||||
start := bytes.Index(body, []byte(startMarker))
|
||||
if start < 0 {
|
||||
return nil, fmt.Errorf("song.link page missing __NEXT_DATA__")
|
||||
}
|
||||
start += len(startMarker)
|
||||
|
||||
end := bytes.Index(body[start:], []byte(endMarker))
|
||||
if end < 0 {
|
||||
return nil, fmt.Errorf("song.link page has unterminated __NEXT_DATA__")
|
||||
}
|
||||
|
||||
return body[start : start+end], nil
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) checkTrackAvailabilityFromISRC(isrc string) (*TrackAvailability, error) {
|
||||
@@ -459,7 +515,7 @@ func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAv
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
retryConfig := DefaultRetryConfig()
|
||||
retryConfig := songLinkRetryConfig()
|
||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check album availability: %w", err)
|
||||
@@ -542,7 +598,7 @@ func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID strin
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
retryConfig := DefaultRetryConfig()
|
||||
retryConfig := songLinkRetryConfig()
|
||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check availability: %w", err)
|
||||
@@ -647,7 +703,7 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
retryConfig := DefaultRetryConfig()
|
||||
retryConfig := songLinkRetryConfig()
|
||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check availability: %w", err)
|
||||
@@ -728,6 +784,51 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
|
||||
return availability, nil
|
||||
}
|
||||
|
||||
func buildTrackAvailabilityFromSongLinkLinks(spotifyTrackID string, links map[string]songLinkPlatformLink) *TrackAvailability {
|
||||
availability := &TrackAvailability{
|
||||
SpotifyID: spotifyTrackID,
|
||||
}
|
||||
|
||||
if availability.SpotifyID == "" {
|
||||
if spotifyLink, ok := links["spotify"]; ok && spotifyLink.URL != "" {
|
||||
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
|
||||
}
|
||||
}
|
||||
if tidalLink, ok := links["tidal"]; ok && tidalLink.URL != "" {
|
||||
availability.Tidal = true
|
||||
availability.TidalURL = tidalLink.URL
|
||||
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
|
||||
}
|
||||
if amazonLink, ok := links["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||
availability.Amazon = true
|
||||
availability.AmazonURL = amazonLink.URL
|
||||
}
|
||||
if qobuzLink, ok := links["qobuz"]; ok && qobuzLink.URL != "" {
|
||||
availability.Qobuz = true
|
||||
availability.QobuzURL = qobuzLink.URL
|
||||
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
|
||||
}
|
||||
if deezerLink, ok := links["deezer"]; ok && deezerLink.URL != "" {
|
||||
availability.Deezer = true
|
||||
availability.DeezerURL = deezerLink.URL
|
||||
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
||||
}
|
||||
if ytMusicLink, ok := links["youtubeMusic"]; ok && ytMusicLink.URL != "" {
|
||||
availability.YouTube = true
|
||||
availability.YouTubeURL = ytMusicLink.URL
|
||||
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
|
||||
}
|
||||
if !availability.YouTube {
|
||||
if youtubeLink, ok := links["youtube"]; ok && youtubeLink.URL != "" {
|
||||
availability.YouTube = true
|
||||
availability.YouTubeURL = youtubeLink.URL
|
||||
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
|
||||
}
|
||||
}
|
||||
|
||||
return availability
|
||||
}
|
||||
|
||||
func extractSpotifyIDFromURL(spotifyURL string) string {
|
||||
parts := strings.Split(spotifyURL, "/track/")
|
||||
if len(parts) > 1 {
|
||||
@@ -802,7 +903,7 @@ func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvaila
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
retryConfig := DefaultRetryConfig()
|
||||
retryConfig := songLinkRetryConfig()
|
||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check availability: %w", err)
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return fn(req)
|
||||
}
|
||||
|
||||
func TestGetRetryAfterDurationMissingHeaderReturnsZero(t *testing.T) {
|
||||
resp := &http.Response{
|
||||
Header: make(http.Header),
|
||||
}
|
||||
|
||||
if got := getRetryAfterDuration(resp); got != 0 {
|
||||
t.Fatalf("getRetryAfterDuration() = %v, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckTrackAvailabilityFromSpotifyPrefersSongLinkPage(t *testing.T) {
|
||||
client := &SongLinkClient{
|
||||
client: &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case req.URL.Host == "api.song.link":
|
||||
t.Fatalf("api.song.link should not be called when song.link page succeeds")
|
||||
return nil, nil
|
||||
case req.URL.Host == "song.link" && req.URL.Path == "/s/testspotifyid":
|
||||
body := `<!DOCTYPE html><html><body><script id="__NEXT_DATA__" type="application/json">{"props":{"pageProps":{"pageData":{"sections":[{"displayName":"Listen","links":[{"platform":"spotify","url":"https://open.spotify.com/track/testspotifyid","show":true},{"platform":"deezer","url":"https://www.deezer.com/track/908604612","show":true},{"platform":"amazonMusic","url":"https://music.amazon.com/albums/B086Q2QNLH?trackAsin=B086Q41M9C","show":true},{"platform":"tidal","url":"https://listen.tidal.com/track/134858527","show":true},{"platform":"qobuz","url":"https://open.qobuz.com/track/195125822","show":true},{"platform":"youtubeMusic","url":"https://music.youtube.com/watch?v=testvideoid1","show":true}]}]}}}}</script></body></html>`
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Request: req,
|
||||
}, nil
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s", req.URL.String())
|
||||
return nil, nil
|
||||
}
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
availability, err := client.CheckTrackAvailability("testspotifyid", "")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckTrackAvailability() error = %v", err)
|
||||
}
|
||||
|
||||
if availability.SpotifyID != "testspotifyid" {
|
||||
t.Fatalf("SpotifyID = %q, want %q", availability.SpotifyID, "testspotifyid")
|
||||
}
|
||||
if !availability.Deezer || availability.DeezerID != "908604612" {
|
||||
t.Fatalf("Deezer availability = %+v, want DeezerID 908604612", availability)
|
||||
}
|
||||
if !availability.Amazon || !availability.Tidal || !availability.Qobuz || !availability.YouTube {
|
||||
t.Fatalf("availability flags = %+v, want Amazon/Tidal/Qobuz/YouTube true", availability)
|
||||
}
|
||||
if availability.YouTubeID != "testvideoid1" {
|
||||
t.Fatalf("YouTubeID = %q, want %q", availability.YouTubeID, "testvideoid1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckTrackAvailabilityFromSpotifyFallsBackToAPIWhenPageFails(t *testing.T) {
|
||||
origRetryConfig := songLinkRetryConfig
|
||||
songLinkRetryConfig = func() RetryConfig {
|
||||
return RetryConfig{
|
||||
MaxRetries: 0,
|
||||
InitialDelay: 0,
|
||||
MaxDelay: 0,
|
||||
BackoffFactor: 1,
|
||||
}
|
||||
}
|
||||
defer func() {
|
||||
songLinkRetryConfig = origRetryConfig
|
||||
}()
|
||||
|
||||
client := &SongLinkClient{
|
||||
client: &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case req.URL.Host == "song.link" && req.URL.Path == "/s/testspotifyid":
|
||||
return &http.Response{
|
||||
StatusCode: 500,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader("page failure")),
|
||||
Request: req,
|
||||
}, nil
|
||||
case req.URL.Host == "api.song.link":
|
||||
body := `{"linksByPlatform":{"spotify":{"url":"https://open.spotify.com/track/testspotifyid"},"deezer":{"url":"https://www.deezer.com/track/908604612"},"amazonMusic":{"url":"https://music.amazon.com/albums/B086Q2QNLH?trackAsin=B086Q41M9C"},"tidal":{"url":"https://listen.tidal.com/track/134858527"},"qobuz":{"url":"https://open.qobuz.com/track/195125822"},"youtubeMusic":{"url":"https://music.youtube.com/watch?v=testvideoid1"}}}`
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Request: req,
|
||||
}, nil
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s", req.URL.String())
|
||||
return nil, nil
|
||||
}
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
availability, err := client.CheckTrackAvailability("testspotifyid", "")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckTrackAvailability() error = %v", err)
|
||||
}
|
||||
|
||||
if availability.SpotifyID != "testspotifyid" {
|
||||
t.Fatalf("SpotifyID = %q, want %q", availability.SpotifyID, "testspotifyid")
|
||||
}
|
||||
if !availability.Deezer || availability.DeezerID != "908604612" {
|
||||
t.Fatalf("Deezer availability = %+v, want DeezerID 908604612", availability)
|
||||
}
|
||||
if !availability.Amazon || !availability.Tidal || !availability.Qobuz || !availability.YouTube {
|
||||
t.Fatalf("availability flags = %+v, want Amazon/Tidal/Qobuz/YouTube true", availability)
|
||||
}
|
||||
if availability.YouTubeID != "testvideoid1" {
|
||||
t.Fatalf("YouTubeID = %q, want %q", availability.YouTubeID, "testvideoid1")
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const DefaultSpotFetchAPIBaseURL = "https://spotify.afkarxyz.fun/api"
|
||||
const DefaultSpotFetchAPIBaseURL = "https://sp.afkarxyz.qzz.io/api"
|
||||
|
||||
// GetSpotifyDataWithAPI fetches Spotify metadata through SpotFetch-compatible API.
|
||||
// This is used as a fallback when direct Spotify API access is blocked/limited.
|
||||
|
||||
+170
-44
@@ -26,8 +26,14 @@ type TidalDownloader struct {
|
||||
}
|
||||
|
||||
var (
|
||||
globalTidalDownloader *TidalDownloader
|
||||
tidalDownloaderOnce sync.Once
|
||||
globalTidalDownloader *TidalDownloader
|
||||
tidalDownloaderOnce sync.Once
|
||||
tidalGetTrackSearchPageFunc = func(t *TidalDownloader, query string, limit int) (*tidalPublicTrackSearchResponse, error) {
|
||||
return t.getTrackSearchPage(query, limit)
|
||||
}
|
||||
tidalGetPublicTrackFunc = func(t *TidalDownloader, resourceID string) (*TidalTrack, error) {
|
||||
return t.getPublicTrack(resourceID)
|
||||
}
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -758,15 +764,101 @@ func (t *TidalDownloader) GetTrackInfoByID(trackID int64) (*TidalTrack, error) {
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) {
|
||||
return nil, fmt.Errorf("tidal ISRC search API disabled: no client credentials mode")
|
||||
normalizedISRC := strings.ToUpper(strings.TrimSpace(isrc))
|
||||
if normalizedISRC == "" {
|
||||
return nil, fmt.Errorf("empty tidal ISRC")
|
||||
}
|
||||
|
||||
page, err := tidalGetTrackSearchPageFunc(t, normalizedISRC, 20)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for i := range page.Items {
|
||||
if strings.EqualFold(strings.TrimSpace(page.Items[i].ISRC), normalizedISRC) {
|
||||
return &page.Items[i], nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no exact tidal ISRC match found for %s", normalizedISRC)
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, albumName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) {
|
||||
return nil, fmt.Errorf("tidal metadata search API disabled: no client credentials mode")
|
||||
queryParts := make([]string, 0, 3)
|
||||
if trimmed := strings.TrimSpace(trackName); trimmed != "" {
|
||||
queryParts = append(queryParts, trimmed)
|
||||
}
|
||||
if trimmed := strings.TrimSpace(artistName); trimmed != "" {
|
||||
queryParts = append(queryParts, trimmed)
|
||||
}
|
||||
if len(queryParts) == 0 {
|
||||
return nil, fmt.Errorf("tidal metadata search requires track or artist name")
|
||||
}
|
||||
|
||||
queries := []string{strings.Join(queryParts, " ")}
|
||||
if trimmedAlbum := strings.TrimSpace(albumName); trimmedAlbum != "" {
|
||||
queries = append(queries, strings.Join(append(queryParts, trimmedAlbum), " "))
|
||||
}
|
||||
|
||||
req := DownloadRequest{
|
||||
TrackName: strings.TrimSpace(trackName),
|
||||
ArtistName: strings.TrimSpace(artistName),
|
||||
AlbumName: strings.TrimSpace(albumName),
|
||||
ISRC: strings.ToUpper(strings.TrimSpace(spotifyISRC)),
|
||||
DurationMS: expectedDuration * 1000,
|
||||
}
|
||||
|
||||
seenQueries := make(map[string]struct{}, len(queries))
|
||||
for _, query := range queries {
|
||||
if _, seen := seenQueries[query]; seen {
|
||||
continue
|
||||
}
|
||||
seenQueries[query] = struct{}{}
|
||||
|
||||
page, err := tidalGetTrackSearchPageFunc(t, query, 20)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var candidates []*TidalTrack
|
||||
for i := range page.Items {
|
||||
track := &page.Items[i]
|
||||
if req.ISRC != "" && !strings.EqualFold(strings.TrimSpace(track.ISRC), req.ISRC) {
|
||||
continue
|
||||
}
|
||||
resolved := resolvedTrackInfo{
|
||||
Title: strings.TrimSpace(track.Title),
|
||||
ArtistName: tidalTrackArtistsDisplay(track),
|
||||
Duration: track.Duration,
|
||||
}
|
||||
if trackMatchesRequest(req, resolved, "Tidal search") {
|
||||
candidates = append(candidates, track)
|
||||
}
|
||||
}
|
||||
|
||||
if len(candidates) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if req.AlbumName != "" {
|
||||
for _, candidate := range candidates {
|
||||
if titlesMatch(req.AlbumName, candidate.Album.Title) {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return candidates[0], nil
|
||||
}
|
||||
|
||||
if req.ISRC != "" {
|
||||
return nil, fmt.Errorf("no tidal metadata match found for exact ISRC %s", req.ISRC)
|
||||
}
|
||||
return nil, fmt.Errorf("no tidal metadata match found")
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) SearchTrackByMetadata(trackName, artistName string) (*TidalTrack, error) {
|
||||
return nil, fmt.Errorf("tidal metadata search API disabled: no client credentials mode")
|
||||
return t.SearchTrackByMetadataWithISRC(trackName, artistName, "", "", 0)
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) SearchTracks(query string, limit int) ([]ExtTrackMetadata, error) {
|
||||
@@ -1847,6 +1939,36 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
|
||||
}
|
||||
}
|
||||
|
||||
if !gotTidalID && req.ISRC != "" {
|
||||
GoLog("[%s] Trying direct Tidal ISRC search: %s\n", logPrefix, req.ISRC)
|
||||
directTrack, directErr := downloader.SearchTrackByISRC(req.ISRC)
|
||||
if directErr == nil && directTrack != nil && directTrack.ID > 0 {
|
||||
trackID = directTrack.ID
|
||||
gotTidalID = true
|
||||
GoLog("[%s] Got Tidal ID %d from direct ISRC search\n", logPrefix, trackID)
|
||||
} else if directErr != nil {
|
||||
GoLog("[%s] Direct Tidal ISRC search failed: %v\n", logPrefix, directErr)
|
||||
}
|
||||
}
|
||||
|
||||
if !gotTidalID && req.ISRC != "" && req.TrackName != "" && req.ArtistName != "" {
|
||||
GoLog("[%s] Trying Tidal public metadata search with ISRC\n", logPrefix)
|
||||
searchTrack, searchErr := downloader.SearchTrackByMetadataWithISRC(
|
||||
req.TrackName,
|
||||
req.ArtistName,
|
||||
req.AlbumName,
|
||||
req.ISRC,
|
||||
expectedDurationSec,
|
||||
)
|
||||
if searchErr == nil && searchTrack != nil && searchTrack.ID > 0 {
|
||||
trackID = searchTrack.ID
|
||||
gotTidalID = true
|
||||
GoLog("[%s] Got Tidal ID %d from public metadata search\n", logPrefix, trackID)
|
||||
} else if searchErr != nil {
|
||||
GoLog("[%s] Tidal public metadata search failed: %v\n", logPrefix, searchErr)
|
||||
}
|
||||
}
|
||||
|
||||
if !gotTidalID && (req.SpotifyID != "" || req.DeezerID != "") {
|
||||
GoLog("[%s] Trying SongLink for Tidal ID...\n", logPrefix)
|
||||
|
||||
@@ -1911,6 +2033,32 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
|
||||
return nil, fmt.Errorf("failed to find tidal track id from request/cache/songlink")
|
||||
}
|
||||
|
||||
// Verify the resolved track matches the request.
|
||||
actualTrack, fetchErr := tidalGetPublicTrackFunc(downloader, strconv.FormatInt(trackID, 10))
|
||||
if fetchErr != nil {
|
||||
GoLog("[%s] Warning: could not fetch Tidal track %d for verification: %v\n", logPrefix, trackID, fetchErr)
|
||||
// Continue without verification — better than failing entirely.
|
||||
} else {
|
||||
providerArtist := actualTrack.Artist.Name
|
||||
if providerArtist == "" && len(actualTrack.Artists) > 0 {
|
||||
providerArtist = actualTrack.Artists[0].Name
|
||||
}
|
||||
resolved := resolvedTrackInfo{
|
||||
Title: actualTrack.Title,
|
||||
ArtistName: providerArtist,
|
||||
Duration: actualTrack.Duration,
|
||||
}
|
||||
if !trackMatchesRequest(req, resolved, logPrefix) {
|
||||
// Invalidate the cached ID so future requests don't reuse it.
|
||||
if req.ISRC != "" {
|
||||
GetTrackIDCache().SetTidal(req.ISRC, 0)
|
||||
}
|
||||
return nil, fmt.Errorf("tidal track %d does not match request: expected '%s - %s', got '%s - %s'",
|
||||
trackID, req.ArtistName, req.TrackName, resolved.ArtistName, resolved.Title)
|
||||
}
|
||||
GoLog("[%s] Track %d verified: '%s - %s' ✓\n", logPrefix, trackID, resolved.ArtistName, resolved.Title)
|
||||
}
|
||||
|
||||
track := &TidalTrack{
|
||||
ID: trackID,
|
||||
Title: strings.TrimSpace(req.TrackName),
|
||||
@@ -1961,11 +2109,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
|
||||
outputExt := strings.TrimSpace(req.OutputExt)
|
||||
if outputExt == "" {
|
||||
if quality == "HIGH" {
|
||||
outputExt = ".m4a"
|
||||
} else {
|
||||
outputExt = ".flac"
|
||||
}
|
||||
outputExt = ".flac"
|
||||
} else if !strings.HasPrefix(outputExt, ".") {
|
||||
outputExt = "." + outputExt
|
||||
}
|
||||
@@ -1979,7 +2123,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
}
|
||||
m4aPath = outputPath
|
||||
} else {
|
||||
if outputExt == ".m4a" || quality == "HIGH" {
|
||||
if outputExt == ".m4a" {
|
||||
filename = sanitizeFilename(filename) + ".m4a"
|
||||
outputPath = filepath.Join(req.OutputDir, filename)
|
||||
m4aPath = outputPath
|
||||
@@ -1992,10 +2136,8 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||
return TidalDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||
}
|
||||
if quality != "HIGH" {
|
||||
if fileInfo, statErr := os.Stat(m4aPath); statErr == nil && fileInfo.Size() > 0 {
|
||||
return TidalDownloadResult{FilePath: "EXISTS:" + m4aPath}, nil
|
||||
}
|
||||
if fileInfo, statErr := os.Stat(m4aPath); statErr == nil && fileInfo.Size() > 0 {
|
||||
return TidalDownloadResult{FilePath: "EXISTS:" + m4aPath}, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2151,27 +2293,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
fmt.Println("[Tidal] No lyrics available from parallel fetch")
|
||||
}
|
||||
} else if (isSafOutput && actualExt == ".m4a") || (!isSafOutput && strings.HasSuffix(actualOutputPath, ".m4a")) {
|
||||
if quality == "HIGH" {
|
||||
GoLog("[Tidal] HIGH quality M4A - skipping metadata embedding (file from server is already valid)\n")
|
||||
|
||||
if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
lyricsMode := req.LyricsMode
|
||||
if lyricsMode == "" {
|
||||
lyricsMode = "embed"
|
||||
}
|
||||
|
||||
if !isSafOutput && (lyricsMode == "external" || lyricsMode == "both") {
|
||||
GoLog("[Tidal] Saving external LRC file for M4A (mode: %s)...\n", lyricsMode)
|
||||
if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
||||
GoLog("[Tidal] Warning: failed to save LRC file: %v\n", lrcErr)
|
||||
} else {
|
||||
GoLog("[Tidal] LRC file saved: %s\n", lrcPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fmt.Println("[Tidal] Skipping metadata embedding for M4A file (will be handled after FFmpeg conversion)")
|
||||
}
|
||||
fmt.Println("[Tidal] Skipping metadata embedding for M4A file (will be handled after FFmpeg conversion)")
|
||||
}
|
||||
|
||||
if !isSafOutput {
|
||||
@@ -2181,24 +2303,28 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
bitDepth := downloadInfo.BitDepth
|
||||
sampleRate := downloadInfo.SampleRate
|
||||
lyricsLRC := ""
|
||||
if quality == "HIGH" {
|
||||
bitDepth = 0
|
||||
sampleRate = 44100
|
||||
}
|
||||
if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
lyricsLRC = parallelResult.LyricsLRC
|
||||
}
|
||||
|
||||
resultAlbum, resultReleaseDate, resultTrackNumber, resultDiscNumber := preferredReleaseMetadata(
|
||||
req,
|
||||
track.Album.Title,
|
||||
track.Album.ReleaseDate,
|
||||
actualTrackNumber,
|
||||
actualDiscNumber,
|
||||
)
|
||||
|
||||
return TidalDownloadResult{
|
||||
FilePath: actualOutputPath,
|
||||
BitDepth: bitDepth,
|
||||
SampleRate: sampleRate,
|
||||
Title: track.Title,
|
||||
Artist: track.Artist.Name,
|
||||
Album: track.Album.Title,
|
||||
ReleaseDate: track.Album.ReleaseDate,
|
||||
TrackNumber: actualTrackNumber,
|
||||
DiscNumber: actualDiscNumber,
|
||||
Album: resultAlbum,
|
||||
ReleaseDate: resultReleaseDate,
|
||||
TrackNumber: resultTrackNumber,
|
||||
DiscNumber: resultDiscNumber,
|
||||
ISRC: track.ISRC,
|
||||
LyricsLRC: lyricsLRC,
|
||||
}, nil
|
||||
|
||||
@@ -68,3 +68,45 @@ func normalizeSymbolOnlyTitle(title string) string {
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ==================== Shared Track Verification ====================
|
||||
|
||||
// resolvedTrackInfo holds the metadata fetched from a provider for verification.
|
||||
type resolvedTrackInfo struct {
|
||||
Title string
|
||||
ArtistName string
|
||||
Duration int // seconds
|
||||
}
|
||||
|
||||
// trackMatchesRequest checks whether a resolved track from a provider matches
|
||||
// the original download request. Returns true if the track is a plausible match.
|
||||
func trackMatchesRequest(req DownloadRequest, resolved resolvedTrackInfo, logPrefix string) bool {
|
||||
if req.ArtistName != "" && resolved.ArtistName != "" &&
|
||||
!artistsMatch(req.ArtistName, resolved.ArtistName) {
|
||||
GoLog("[%s] Verification failed: artist mismatch — expected '%s', got '%s'\n",
|
||||
logPrefix, req.ArtistName, resolved.ArtistName)
|
||||
return false
|
||||
}
|
||||
|
||||
if req.TrackName != "" && resolved.Title != "" &&
|
||||
!titlesMatch(req.TrackName, resolved.Title) {
|
||||
GoLog("[%s] Verification failed: title mismatch — expected '%s', got '%s'\n",
|
||||
logPrefix, req.TrackName, resolved.Title)
|
||||
return false
|
||||
}
|
||||
|
||||
expectedDurationSec := req.DurationMS / 1000
|
||||
if expectedDurationSec > 0 && resolved.Duration > 0 {
|
||||
diff := expectedDurationSec - resolved.Duration
|
||||
if diff < 0 {
|
||||
diff = -diff
|
||||
}
|
||||
if diff > 10 {
|
||||
GoLog("[%s] Verification failed: duration mismatch — expected %ds, got %ds\n",
|
||||
logPrefix, expectedDurationSec, resolved.Duration)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ var (
|
||||
type YouTubeQuality string
|
||||
|
||||
const (
|
||||
YouTubeQualityOpus320 YouTubeQuality = "opus_320"
|
||||
YouTubeQualityOpus256 YouTubeQuality = "opus_256"
|
||||
YouTubeQualityOpus128 YouTubeQuality = "opus_128"
|
||||
YouTubeQualityMP3128 YouTubeQuality = "mp3_128"
|
||||
@@ -37,7 +38,7 @@ const (
|
||||
)
|
||||
|
||||
var (
|
||||
youtubeOpusSupportedBitrates = []int{128, 256}
|
||||
youtubeOpusSupportedBitrates = []int{128, 256, 320}
|
||||
youtubeMp3SupportedBitrates = []int{128, 256, 320}
|
||||
)
|
||||
|
||||
@@ -146,6 +147,8 @@ func parseYouTubeQualityInput(raw string) (format string, bitrate int, normalize
|
||||
switch normalizedRaw {
|
||||
case "opus_256", "opus256", "opus":
|
||||
return "opus", 256, YouTubeQualityOpus256
|
||||
case "opus_320", "opus320":
|
||||
return "opus", 320, YouTubeQualityOpus320
|
||||
case "opus_128", "opus128":
|
||||
return "opus", 128, YouTubeQualityOpus128
|
||||
case "mp3_320", "mp3320", "mp3", "":
|
||||
|
||||
@@ -30,8 +30,8 @@ func TestParseYouTubeQualityInput_Mp3NormalizesToSupportedBitrates(t *testing.T)
|
||||
|
||||
func TestParseYouTubeQualityInput_PicksNearestSupportedBitrate(t *testing.T) {
|
||||
_, opusBitrate, _ := parseYouTubeQualityInput("opus_999")
|
||||
if opusBitrate != 256 {
|
||||
t.Fatalf("expected opus normalization to 256, got %d", opusBitrate)
|
||||
if opusBitrate != 320 {
|
||||
t.Fatalf("expected opus normalization to 320, got %d", opusBitrate)
|
||||
}
|
||||
|
||||
_, mp3Bitrate, _ := parseYouTubeQualityInput("mp3_1")
|
||||
@@ -39,3 +39,16 @@ func TestParseYouTubeQualityInput_PicksNearestSupportedBitrate(t *testing.T) {
|
||||
t.Fatalf("expected mp3 normalization to 128, got %d", mp3Bitrate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseYouTubeQualityInput_Opus320(t *testing.T) {
|
||||
format, bitrate, normalized := parseYouTubeQualityInput("opus_320")
|
||||
if format != "opus" {
|
||||
t.Fatalf("expected opus format, got %s", format)
|
||||
}
|
||||
if bitrate != 320 {
|
||||
t.Fatalf("expected 320 bitrate, got %d", bitrate)
|
||||
}
|
||||
if normalized != YouTubeQualityOpus320 {
|
||||
t.Fatalf("expected %s normalized, got %s", YouTubeQualityOpus320, normalized)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ import 'package:flutter/foundation.dart';
|
||||
/// App version and info constants
|
||||
/// Update version here only - all other files will reference this
|
||||
class AppInfo {
|
||||
static const String version = '3.8.0';
|
||||
static const String buildNumber = '106';
|
||||
static const String version = '3.8.8';
|
||||
static const String buildNumber = '114';
|
||||
static const String fullVersion = '$version+$buildNumber';
|
||||
|
||||
/// Shows "Internal" in debug builds, actual version in release.
|
||||
|
||||
+251
-68
@@ -1066,6 +1066,12 @@ abstract class AppLocalizations {
|
||||
/// **'Import'**
|
||||
String get dialogImport;
|
||||
|
||||
/// Confirm button in Download All dialog
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Download'**
|
||||
String get dialogDownload;
|
||||
|
||||
/// Dialog button - discard changes
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -3100,6 +3106,42 @@ abstract class AppLocalizations {
|
||||
/// **'Show when searching for existing tracks'**
|
||||
String get libraryShowDuplicateIndicatorSubtitle;
|
||||
|
||||
/// Setting for automatic library scanning
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Auto Scan'**
|
||||
String get libraryAutoScan;
|
||||
|
||||
/// Subtitle for auto scan setting
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Automatically scan your library for new files'**
|
||||
String get libraryAutoScanSubtitle;
|
||||
|
||||
/// Auto scan disabled
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Off'**
|
||||
String get libraryAutoScanOff;
|
||||
|
||||
/// Auto scan when app opens
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Every app open'**
|
||||
String get libraryAutoScanOnOpen;
|
||||
|
||||
/// Auto scan once per day
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Daily'**
|
||||
String get libraryAutoScanDaily;
|
||||
|
||||
/// Auto scan once per week
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Weekly'**
|
||||
String get libraryAutoScanWeekly;
|
||||
|
||||
/// Section header for library actions
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -3832,6 +3874,36 @@ abstract class AppLocalizations {
|
||||
/// **'FFmpeg metadata embed failed'**
|
||||
String get trackReEnrichFfmpegFailed;
|
||||
|
||||
/// Action/button label for queueing FLAC redownloads for local tracks
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Queue FLAC'**
|
||||
String get queueFlacAction;
|
||||
|
||||
/// Confirmation dialog body before queueing FLAC redownloads for local tracks
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n{count} selected'**
|
||||
String queueFlacConfirmMessage(int count);
|
||||
|
||||
/// Snackbar while resolving remote matches for local FLAC redownloads
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Finding FLAC matches... ({current}/{total})'**
|
||||
String queueFlacFindingProgress(int current, int total);
|
||||
|
||||
/// Snackbar when no safe FLAC redownload matches were found
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No reliable online matches found for the selection'**
|
||||
String get queueFlacNoReliableMatches;
|
||||
|
||||
/// Snackbar when some selected local tracks were queued for FLAC redownload and some were skipped
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Added {addedCount} tracks to queue, skipped {skippedCount}'**
|
||||
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount);
|
||||
|
||||
/// Snackbar when save operation fails
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -3847,7 +3919,7 @@ abstract class AppLocalizations {
|
||||
/// Subtitle for convert format menu item
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Convert to MP3 or Opus'**
|
||||
/// **'Convert to MP3, Opus, ALAC, or FLAC'**
|
||||
String get trackConvertFormatSubtitle;
|
||||
|
||||
/// Title of convert bottom sheet
|
||||
@@ -3884,6 +3956,21 @@ abstract class AppLocalizations {
|
||||
String bitrate,
|
||||
);
|
||||
|
||||
/// Confirmation dialog message for lossless-to-lossless conversion
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Convert from {sourceFormat} to {targetFormat}? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.'**
|
||||
String trackConvertConfirmMessageLossless(
|
||||
String sourceFormat,
|
||||
String targetFormat,
|
||||
);
|
||||
|
||||
/// Hint shown when converting between lossless formats
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Lossless conversion — no quality loss'**
|
||||
String get trackConvertLosslessHint;
|
||||
|
||||
/// Snackbar while converting
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -4254,6 +4341,12 @@ abstract class AppLocalizations {
|
||||
String bitrate,
|
||||
);
|
||||
|
||||
/// Confirmation dialog message for lossless batch conversion
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Convert {count} {count, plural, =1{track} other{tracks}} to {format}? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.'**
|
||||
String selectionBatchConvertConfirmMessageLossless(int count, String format);
|
||||
|
||||
/// Snackbar during batch conversion progress
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -4434,7 +4527,7 @@ abstract class AppLocalizations {
|
||||
/// **'Added {count} tracks to Loved'**
|
||||
String snackbarAddedTracksToLoved(int count);
|
||||
|
||||
/// Title of the Download All confirmation dialog
|
||||
/// Dialog title for bulk download confirmation
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Download All'**
|
||||
@@ -4446,12 +4539,6 @@ abstract class AppLocalizations {
|
||||
/// **'Download {count} tracks?'**
|
||||
String dialogDownloadAllMessage(int count);
|
||||
|
||||
/// Confirm button in Download All dialog
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Download'**
|
||||
String get dialogDownload;
|
||||
|
||||
/// Checkbox label in import dialog to skip already-downloaded songs
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -4602,18 +4689,6 @@ abstract class AppLocalizations {
|
||||
/// **'Select a built-in service to enable'**
|
||||
String get downloadSelectServiceToEnable;
|
||||
|
||||
/// Quality option label for Tidal lossy 320kbps
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Lossy 320kbps'**
|
||||
String get downloadLossy320;
|
||||
|
||||
/// Setting title to pick output format for Tidal lossy downloads
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Lossy Format'**
|
||||
String get downloadLossyFormat;
|
||||
|
||||
/// Info hint when non-Tidal/Qobuz service is selected
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -4740,54 +4815,6 @@ abstract class AppLocalizations {
|
||||
/// **'Auto'**
|
||||
String get downloadMusixmatchAuto;
|
||||
|
||||
/// Title of the Tidal lossy format picker bottom sheet
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Lossy 320kbps Format'**
|
||||
String get downloadLossy320Format;
|
||||
|
||||
/// Description in the Tidal lossy format picker
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.'**
|
||||
String get downloadLossy320FormatDesc;
|
||||
|
||||
/// Tidal lossy format option - MP3 320kbps
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'MP3 320kbps'**
|
||||
String get downloadLossyMp3;
|
||||
|
||||
/// Subtitle for MP3 320kbps option
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Best compatibility, ~10MB per track'**
|
||||
String get downloadLossyMp3Subtitle;
|
||||
|
||||
/// Tidal lossy format option - Opus 256kbps
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Opus 256kbps'**
|
||||
String get downloadLossyOpus256;
|
||||
|
||||
/// Subtitle for Opus 256kbps option
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Best quality Opus, ~8MB per track'**
|
||||
String get downloadLossyOpus256Subtitle;
|
||||
|
||||
/// Tidal lossy format option - Opus 128kbps
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Opus 128kbps'**
|
||||
String get downloadLossyOpus128;
|
||||
|
||||
/// Subtitle for Opus 128kbps option
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Smallest size, ~4MB per track'**
|
||||
String get downloadLossyOpus128Subtitle;
|
||||
|
||||
/// Subtitle for 'Any' network mode option
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -4817,6 +4844,162 @@ abstract class AppLocalizations {
|
||||
/// In en, this message translates to:
|
||||
/// **'Refresh'**
|
||||
String get cacheRefresh;
|
||||
|
||||
/// Dialog message for bulk playlist download confirmation
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Download {trackCount} {trackCount, plural, =1{track} other{tracks}} from {playlistCount} {playlistCount, plural, =1{playlist} other{playlists}}?'**
|
||||
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount);
|
||||
|
||||
/// Button label for bulk downloading selected playlists
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Download {count} {count, plural, =1{playlist} other{playlists}}'**
|
||||
String bulkDownloadPlaylistsButton(int count);
|
||||
|
||||
/// Button label when no playlists are selected for download
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Select playlists to download'**
|
||||
String get bulkDownloadSelectPlaylists;
|
||||
|
||||
/// Snackbar when selected playlists contain no tracks
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Selected playlists have no tracks'**
|
||||
String get snackbarSelectedPlaylistsEmpty;
|
||||
|
||||
/// Playlist count display
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{1 playlist} other{{count} playlists}}'**
|
||||
String playlistsCount(int count);
|
||||
|
||||
/// Section title for selective online metadata auto-fill in the edit metadata sheet
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Auto-fill from online'**
|
||||
String get editMetadataAutoFill;
|
||||
|
||||
/// Description for the auto-fill section
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Select fields to fill automatically from online metadata'**
|
||||
String get editMetadataAutoFillDesc;
|
||||
|
||||
/// Button label to fetch online metadata and fill selected fields
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Fetch & Fill'**
|
||||
String get editMetadataAutoFillFetch;
|
||||
|
||||
/// Snackbar shown while searching for online metadata
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Searching online...'**
|
||||
String get editMetadataAutoFillSearching;
|
||||
|
||||
/// Snackbar when online metadata search returns no results
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No matching metadata found online'**
|
||||
String get editMetadataAutoFillNoResults;
|
||||
|
||||
/// Snackbar confirming how many fields were auto-filled
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Filled {count} {count, plural, =1{field} other{fields}} from online metadata'**
|
||||
String editMetadataAutoFillDone(int count);
|
||||
|
||||
/// Snackbar when user taps Fetch without selecting any fields
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Select at least one field to auto-fill'**
|
||||
String get editMetadataAutoFillNoneSelected;
|
||||
|
||||
/// Chip label for title field in auto-fill selector
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Title'**
|
||||
String get editMetadataFieldTitle;
|
||||
|
||||
/// Chip label for artist field in auto-fill selector
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Artist'**
|
||||
String get editMetadataFieldArtist;
|
||||
|
||||
/// Chip label for album field in auto-fill selector
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Album'**
|
||||
String get editMetadataFieldAlbum;
|
||||
|
||||
/// Chip label for album artist field in auto-fill selector
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Album Artist'**
|
||||
String get editMetadataFieldAlbumArtist;
|
||||
|
||||
/// Chip label for date field in auto-fill selector
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Date'**
|
||||
String get editMetadataFieldDate;
|
||||
|
||||
/// Chip label for track number field in auto-fill selector
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Track #'**
|
||||
String get editMetadataFieldTrackNum;
|
||||
|
||||
/// Chip label for disc number field in auto-fill selector
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Disc #'**
|
||||
String get editMetadataFieldDiscNum;
|
||||
|
||||
/// Chip label for genre field in auto-fill selector
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Genre'**
|
||||
String get editMetadataFieldGenre;
|
||||
|
||||
/// Chip label for ISRC field in auto-fill selector
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'ISRC'**
|
||||
String get editMetadataFieldIsrc;
|
||||
|
||||
/// Chip label for label field in auto-fill selector
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Label'**
|
||||
String get editMetadataFieldLabel;
|
||||
|
||||
/// Chip label for copyright field in auto-fill selector
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Copyright'**
|
||||
String get editMetadataFieldCopyright;
|
||||
|
||||
/// Chip label for cover art field in auto-fill selector
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Cover Art'**
|
||||
String get editMetadataFieldCover;
|
||||
|
||||
/// Button to select all fields for auto-fill
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'All'**
|
||||
String get editMetadataSelectAll;
|
||||
|
||||
/// Button to select only fields that are currently empty
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Empty only'**
|
||||
String get editMetadataSelectEmpty;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
||||
@@ -536,6 +536,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get dialogImport => 'Importieren';
|
||||
|
||||
@override
|
||||
String get dialogDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get dialogDiscard => 'Verwerfen';
|
||||
|
||||
@@ -1716,6 +1719,25 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Bei der Suche nach vorhandenen Titeln anzeigen';
|
||||
|
||||
@override
|
||||
String get libraryAutoScan => 'Auto Scan';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanSubtitle =>
|
||||
'Automatically scan your library for new files';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOff => 'Off';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOnOpen => 'Every app open';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanDaily => 'Daily';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanWeekly => 'Weekly';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Aktionen';
|
||||
|
||||
@@ -2169,6 +2191,28 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get trackReEnrichFfmpegFailed =>
|
||||
'FFmpeg Metadaten-Einbettung fehlgeschlagen';
|
||||
|
||||
@override
|
||||
String get queueFlacAction => 'Queue FLAC';
|
||||
|
||||
@override
|
||||
String queueFlacConfirmMessage(int count) {
|
||||
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueFlacFindingProgress(int current, int total) {
|
||||
return 'Finding FLAC matches... ($current/$total)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueFlacNoReliableMatches =>
|
||||
'No reliable online matches found for the selection';
|
||||
|
||||
@override
|
||||
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
|
||||
return 'Added $addedCount tracks to queue, skipped $skippedCount';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return 'Fehler: $error';
|
||||
@@ -2201,6 +2245,18 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
return 'Konvertieren von $sourceFormat in $targetFormat bei $bitrate?\n\nDie Originaldatei wird nach der Konvertierung gelöscht.';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackConvertConfirmMessageLossless(
|
||||
String sourceFormat,
|
||||
String targetFormat,
|
||||
) {
|
||||
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertLosslessHint =>
|
||||
'Lossless conversion — no quality loss';
|
||||
|
||||
@override
|
||||
String get trackConvertConverting => 'Konvertiere Audio...';
|
||||
|
||||
@@ -2455,6 +2511,17 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
return 'Konvertiere $count $format $_temp0 zu $bitrate?\n\nOriginaldateien werden nach der Konvertierung gelöscht.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertProgress(int current, int total) {
|
||||
return 'Konvertiere $current von $total...';
|
||||
@@ -2580,9 +2647,6 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
return 'Download $count tracks?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get dialogDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
|
||||
|
||||
@@ -2666,12 +2730,6 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get downloadSelectServiceToEnable =>
|
||||
'Select a built-in service to enable';
|
||||
|
||||
@override
|
||||
String get downloadLossy320 => 'Lossy 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get downloadSelectTidalQobuz =>
|
||||
'Select Tidal or Qobuz above to configure quality';
|
||||
@@ -2748,32 +2806,6 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get downloadMusixmatchAuto => 'Auto';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256Subtitle =>
|
||||
'Best quality Opus, ~8MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||
|
||||
@override
|
||||
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
||||
|
||||
@@ -2790,4 +2822,124 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get cacheRefresh => 'Refresh';
|
||||
|
||||
@override
|
||||
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
trackCount,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
String _temp1 = intl.Intl.pluralLogic(
|
||||
playlistCount,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
|
||||
}
|
||||
|
||||
@override
|
||||
String bulkDownloadPlaylistsButton(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
|
||||
|
||||
@override
|
||||
String get snackbarSelectedPlaylistsEmpty =>
|
||||
'Selected playlists have no tracks';
|
||||
|
||||
@override
|
||||
String playlistsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count playlists',
|
||||
one: '1 playlist',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFill => 'Auto-fill from online';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillDesc =>
|
||||
'Select fields to fill automatically from online metadata';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillFetch => 'Fetch & Fill';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillSearching => 'Searching online...';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoResults =>
|
||||
'No matching metadata found online';
|
||||
|
||||
@override
|
||||
String editMetadataAutoFillDone(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'fields',
|
||||
one: 'field',
|
||||
);
|
||||
return 'Filled $count $_temp0 from online metadata';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoneSelected =>
|
||||
'Select at least one field to auto-fill';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTitle => 'Title';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldArtist => 'Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbum => 'Album';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbumArtist => 'Album Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDate => 'Date';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTrackNum => 'Track #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDiscNum => 'Disc #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldGenre => 'Genre';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldIsrc => 'ISRC';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldLabel => 'Label';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCopyright => 'Copyright';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCover => 'Cover Art';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectAll => 'All';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectEmpty => 'Empty only';
|
||||
}
|
||||
|
||||
@@ -525,6 +525,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get dialogImport => 'Import';
|
||||
|
||||
@override
|
||||
String get dialogDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get dialogDiscard => 'Discard';
|
||||
|
||||
@@ -1692,6 +1695,25 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryAutoScan => 'Auto Scan';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanSubtitle =>
|
||||
'Automatically scan your library for new files';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOff => 'Off';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOnOpen => 'Every app open';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanDaily => 'Daily';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanWeekly => 'Weekly';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@@ -2142,6 +2164,28 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||
|
||||
@override
|
||||
String get queueFlacAction => 'Queue FLAC';
|
||||
|
||||
@override
|
||||
String queueFlacConfirmMessage(int count) {
|
||||
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueFlacFindingProgress(int current, int total) {
|
||||
return 'Finding FLAC matches... ($current/$total)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueFlacNoReliableMatches =>
|
||||
'No reliable online matches found for the selection';
|
||||
|
||||
@override
|
||||
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
|
||||
return 'Added $addedCount tracks to queue, skipped $skippedCount';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return 'Failed: $error';
|
||||
@@ -2151,7 +2195,8 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get trackConvertFormat => 'Convert Format';
|
||||
|
||||
@override
|
||||
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||
String get trackConvertFormatSubtitle =>
|
||||
'Convert to MP3, Opus, ALAC, or FLAC';
|
||||
|
||||
@override
|
||||
String get trackConvertTitle => 'Convert Audio';
|
||||
@@ -2174,6 +2219,18 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackConvertConfirmMessageLossless(
|
||||
String sourceFormat,
|
||||
String targetFormat,
|
||||
) {
|
||||
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertLosslessHint =>
|
||||
'Lossless conversion — no quality loss';
|
||||
|
||||
@override
|
||||
String get trackConvertConverting => 'Converting audio...';
|
||||
|
||||
@@ -2427,6 +2484,17 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertProgress(int current, int total) {
|
||||
return 'Converting $current of $total...';
|
||||
@@ -2552,9 +2620,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
return 'Download $count tracks?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get dialogDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
|
||||
|
||||
@@ -2638,12 +2703,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get downloadSelectServiceToEnable =>
|
||||
'Select a built-in service to enable';
|
||||
|
||||
@override
|
||||
String get downloadLossy320 => 'Lossy 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get downloadSelectTidalQobuz =>
|
||||
'Select Tidal or Qobuz above to configure quality';
|
||||
@@ -2720,32 +2779,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get downloadMusixmatchAuto => 'Auto';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256Subtitle =>
|
||||
'Best quality Opus, ~8MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||
|
||||
@override
|
||||
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
||||
|
||||
@@ -2762,4 +2795,124 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get cacheRefresh => 'Refresh';
|
||||
|
||||
@override
|
||||
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
trackCount,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
String _temp1 = intl.Intl.pluralLogic(
|
||||
playlistCount,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
|
||||
}
|
||||
|
||||
@override
|
||||
String bulkDownloadPlaylistsButton(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
|
||||
|
||||
@override
|
||||
String get snackbarSelectedPlaylistsEmpty =>
|
||||
'Selected playlists have no tracks';
|
||||
|
||||
@override
|
||||
String playlistsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count playlists',
|
||||
one: '1 playlist',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFill => 'Auto-fill from online';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillDesc =>
|
||||
'Select fields to fill automatically from online metadata';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillFetch => 'Fetch & Fill';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillSearching => 'Searching online...';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoResults =>
|
||||
'No matching metadata found online';
|
||||
|
||||
@override
|
||||
String editMetadataAutoFillDone(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'fields',
|
||||
one: 'field',
|
||||
);
|
||||
return 'Filled $count $_temp0 from online metadata';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoneSelected =>
|
||||
'Select at least one field to auto-fill';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTitle => 'Title';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldArtist => 'Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbum => 'Album';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbumArtist => 'Album Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDate => 'Date';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTrackNum => 'Track #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDiscNum => 'Disc #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldGenre => 'Genre';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldIsrc => 'ISRC';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldLabel => 'Label';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCopyright => 'Copyright';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCover => 'Cover Art';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectAll => 'All';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectEmpty => 'Empty only';
|
||||
}
|
||||
|
||||
@@ -525,6 +525,9 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get dialogImport => 'Import';
|
||||
|
||||
@override
|
||||
String get dialogDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get dialogDiscard => 'Discard';
|
||||
|
||||
@@ -1692,6 +1695,25 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryAutoScan => 'Auto Scan';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanSubtitle =>
|
||||
'Automatically scan your library for new files';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOff => 'Off';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOnOpen => 'Every app open';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanDaily => 'Daily';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanWeekly => 'Weekly';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@@ -2142,6 +2164,28 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||
|
||||
@override
|
||||
String get queueFlacAction => 'Queue FLAC';
|
||||
|
||||
@override
|
||||
String queueFlacConfirmMessage(int count) {
|
||||
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueFlacFindingProgress(int current, int total) {
|
||||
return 'Finding FLAC matches... ($current/$total)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueFlacNoReliableMatches =>
|
||||
'No reliable online matches found for the selection';
|
||||
|
||||
@override
|
||||
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
|
||||
return 'Added $addedCount tracks to queue, skipped $skippedCount';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return 'Failed: $error';
|
||||
@@ -2151,7 +2195,8 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
String get trackConvertFormat => 'Convert Format';
|
||||
|
||||
@override
|
||||
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||
String get trackConvertFormatSubtitle =>
|
||||
'Convert to MP3, Opus, ALAC, or FLAC';
|
||||
|
||||
@override
|
||||
String get trackConvertTitle => 'Convert Audio';
|
||||
@@ -2174,6 +2219,18 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackConvertConfirmMessageLossless(
|
||||
String sourceFormat,
|
||||
String targetFormat,
|
||||
) {
|
||||
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertLosslessHint =>
|
||||
'Lossless conversion — no quality loss';
|
||||
|
||||
@override
|
||||
String get trackConvertConverting => 'Converting audio...';
|
||||
|
||||
@@ -2427,6 +2484,17 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertProgress(int current, int total) {
|
||||
return 'Converting $current of $total...';
|
||||
@@ -2552,9 +2620,6 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
return 'Download $count tracks?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get dialogDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
|
||||
|
||||
@@ -2638,12 +2703,6 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
String get downloadSelectServiceToEnable =>
|
||||
'Select a built-in service to enable';
|
||||
|
||||
@override
|
||||
String get downloadLossy320 => 'Lossy 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get downloadSelectTidalQobuz =>
|
||||
'Select Tidal or Qobuz above to configure quality';
|
||||
@@ -2720,32 +2779,6 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get downloadMusixmatchAuto => 'Auto';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256Subtitle =>
|
||||
'Best quality Opus, ~8MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||
|
||||
@override
|
||||
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
||||
|
||||
@@ -2762,6 +2795,126 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get cacheRefresh => 'Refresh';
|
||||
|
||||
@override
|
||||
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
trackCount,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
String _temp1 = intl.Intl.pluralLogic(
|
||||
playlistCount,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
|
||||
}
|
||||
|
||||
@override
|
||||
String bulkDownloadPlaylistsButton(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
|
||||
|
||||
@override
|
||||
String get snackbarSelectedPlaylistsEmpty =>
|
||||
'Selected playlists have no tracks';
|
||||
|
||||
@override
|
||||
String playlistsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count playlists',
|
||||
one: '1 playlist',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFill => 'Auto-fill from online';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillDesc =>
|
||||
'Select fields to fill automatically from online metadata';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillFetch => 'Fetch & Fill';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillSearching => 'Searching online...';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoResults =>
|
||||
'No matching metadata found online';
|
||||
|
||||
@override
|
||||
String editMetadataAutoFillDone(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'fields',
|
||||
one: 'field',
|
||||
);
|
||||
return 'Filled $count $_temp0 from online metadata';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoneSelected =>
|
||||
'Select at least one field to auto-fill';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTitle => 'Title';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldArtist => 'Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbum => 'Album';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbumArtist => 'Album Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDate => 'Date';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTrackNum => 'Track #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDiscNum => 'Disc #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldGenre => 'Genre';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldIsrc => 'ISRC';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldLabel => 'Label';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCopyright => 'Copyright';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCover => 'Cover Art';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectAll => 'All';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectEmpty => 'Empty only';
|
||||
}
|
||||
|
||||
/// The translations for Spanish Castilian, as used in Spain (`es_ES`).
|
||||
|
||||
@@ -527,6 +527,9 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get dialogImport => 'Import';
|
||||
|
||||
@override
|
||||
String get dialogDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get dialogDiscard => 'Discard';
|
||||
|
||||
@@ -1694,6 +1697,25 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryAutoScan => 'Auto Scan';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanSubtitle =>
|
||||
'Automatically scan your library for new files';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOff => 'Off';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOnOpen => 'Every app open';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanDaily => 'Daily';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanWeekly => 'Weekly';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@@ -2144,6 +2166,28 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||
|
||||
@override
|
||||
String get queueFlacAction => 'Queue FLAC';
|
||||
|
||||
@override
|
||||
String queueFlacConfirmMessage(int count) {
|
||||
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueFlacFindingProgress(int current, int total) {
|
||||
return 'Finding FLAC matches... ($current/$total)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueFlacNoReliableMatches =>
|
||||
'No reliable online matches found for the selection';
|
||||
|
||||
@override
|
||||
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
|
||||
return 'Added $addedCount tracks to queue, skipped $skippedCount';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return 'Failed: $error';
|
||||
@@ -2176,6 +2220,18 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackConvertConfirmMessageLossless(
|
||||
String sourceFormat,
|
||||
String targetFormat,
|
||||
) {
|
||||
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertLosslessHint =>
|
||||
'Lossless conversion — no quality loss';
|
||||
|
||||
@override
|
||||
String get trackConvertConverting => 'Converting audio...';
|
||||
|
||||
@@ -2429,6 +2485,17 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertProgress(int current, int total) {
|
||||
return 'Converting $current of $total...';
|
||||
@@ -2554,9 +2621,6 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
return 'Download $count tracks?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get dialogDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
|
||||
|
||||
@@ -2640,12 +2704,6 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get downloadSelectServiceToEnable =>
|
||||
'Select a built-in service to enable';
|
||||
|
||||
@override
|
||||
String get downloadLossy320 => 'Lossy 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get downloadSelectTidalQobuz =>
|
||||
'Select Tidal or Qobuz above to configure quality';
|
||||
@@ -2722,32 +2780,6 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get downloadMusixmatchAuto => 'Auto';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256Subtitle =>
|
||||
'Best quality Opus, ~8MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||
|
||||
@override
|
||||
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
||||
|
||||
@@ -2764,4 +2796,124 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get cacheRefresh => 'Refresh';
|
||||
|
||||
@override
|
||||
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
trackCount,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
String _temp1 = intl.Intl.pluralLogic(
|
||||
playlistCount,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
|
||||
}
|
||||
|
||||
@override
|
||||
String bulkDownloadPlaylistsButton(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
|
||||
|
||||
@override
|
||||
String get snackbarSelectedPlaylistsEmpty =>
|
||||
'Selected playlists have no tracks';
|
||||
|
||||
@override
|
||||
String playlistsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count playlists',
|
||||
one: '1 playlist',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFill => 'Auto-fill from online';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillDesc =>
|
||||
'Select fields to fill automatically from online metadata';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillFetch => 'Fetch & Fill';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillSearching => 'Searching online...';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoResults =>
|
||||
'No matching metadata found online';
|
||||
|
||||
@override
|
||||
String editMetadataAutoFillDone(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'fields',
|
||||
one: 'field',
|
||||
);
|
||||
return 'Filled $count $_temp0 from online metadata';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoneSelected =>
|
||||
'Select at least one field to auto-fill';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTitle => 'Title';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldArtist => 'Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbum => 'Album';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbumArtist => 'Album Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDate => 'Date';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTrackNum => 'Track #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDiscNum => 'Disc #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldGenre => 'Genre';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldIsrc => 'ISRC';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldLabel => 'Label';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCopyright => 'Copyright';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCover => 'Cover Art';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectAll => 'All';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectEmpty => 'Empty only';
|
||||
}
|
||||
|
||||
@@ -525,6 +525,9 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get dialogImport => 'Import';
|
||||
|
||||
@override
|
||||
String get dialogDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get dialogDiscard => 'Discard';
|
||||
|
||||
@@ -1692,6 +1695,25 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryAutoScan => 'Auto Scan';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanSubtitle =>
|
||||
'Automatically scan your library for new files';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOff => 'Off';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOnOpen => 'Every app open';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanDaily => 'Daily';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanWeekly => 'Weekly';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@@ -2142,6 +2164,28 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||
|
||||
@override
|
||||
String get queueFlacAction => 'Queue FLAC';
|
||||
|
||||
@override
|
||||
String queueFlacConfirmMessage(int count) {
|
||||
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueFlacFindingProgress(int current, int total) {
|
||||
return 'Finding FLAC matches... ($current/$total)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueFlacNoReliableMatches =>
|
||||
'No reliable online matches found for the selection';
|
||||
|
||||
@override
|
||||
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
|
||||
return 'Added $addedCount tracks to queue, skipped $skippedCount';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return 'Failed: $error';
|
||||
@@ -2174,6 +2218,18 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackConvertConfirmMessageLossless(
|
||||
String sourceFormat,
|
||||
String targetFormat,
|
||||
) {
|
||||
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertLosslessHint =>
|
||||
'Lossless conversion — no quality loss';
|
||||
|
||||
@override
|
||||
String get trackConvertConverting => 'Converting audio...';
|
||||
|
||||
@@ -2427,6 +2483,17 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertProgress(int current, int total) {
|
||||
return 'Converting $current of $total...';
|
||||
@@ -2552,9 +2619,6 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
return 'Download $count tracks?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get dialogDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
|
||||
|
||||
@@ -2638,12 +2702,6 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String get downloadSelectServiceToEnable =>
|
||||
'Select a built-in service to enable';
|
||||
|
||||
@override
|
||||
String get downloadLossy320 => 'Lossy 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get downloadSelectTidalQobuz =>
|
||||
'Select Tidal or Qobuz above to configure quality';
|
||||
@@ -2720,32 +2778,6 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get downloadMusixmatchAuto => 'Auto';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256Subtitle =>
|
||||
'Best quality Opus, ~8MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||
|
||||
@override
|
||||
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
||||
|
||||
@@ -2762,4 +2794,124 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get cacheRefresh => 'Refresh';
|
||||
|
||||
@override
|
||||
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
trackCount,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
String _temp1 = intl.Intl.pluralLogic(
|
||||
playlistCount,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
|
||||
}
|
||||
|
||||
@override
|
||||
String bulkDownloadPlaylistsButton(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
|
||||
|
||||
@override
|
||||
String get snackbarSelectedPlaylistsEmpty =>
|
||||
'Selected playlists have no tracks';
|
||||
|
||||
@override
|
||||
String playlistsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count playlists',
|
||||
one: '1 playlist',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFill => 'Auto-fill from online';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillDesc =>
|
||||
'Select fields to fill automatically from online metadata';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillFetch => 'Fetch & Fill';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillSearching => 'Searching online...';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoResults =>
|
||||
'No matching metadata found online';
|
||||
|
||||
@override
|
||||
String editMetadataAutoFillDone(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'fields',
|
||||
one: 'field',
|
||||
);
|
||||
return 'Filled $count $_temp0 from online metadata';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoneSelected =>
|
||||
'Select at least one field to auto-fill';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTitle => 'Title';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldArtist => 'Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbum => 'Album';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbumArtist => 'Album Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDate => 'Date';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTrackNum => 'Track #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDiscNum => 'Disc #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldGenre => 'Genre';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldIsrc => 'ISRC';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldLabel => 'Label';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCopyright => 'Copyright';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCover => 'Cover Art';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectAll => 'All';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectEmpty => 'Empty only';
|
||||
}
|
||||
|
||||
@@ -528,6 +528,9 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get dialogImport => 'Impor';
|
||||
|
||||
@override
|
||||
String get dialogDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get dialogDiscard => 'Buang';
|
||||
|
||||
@@ -1699,6 +1702,25 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryAutoScan => 'Auto Scan';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanSubtitle =>
|
||||
'Automatically scan your library for new files';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOff => 'Off';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOnOpen => 'Every app open';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanDaily => 'Daily';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanWeekly => 'Weekly';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@@ -2149,6 +2171,28 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||
|
||||
@override
|
||||
String get queueFlacAction => 'Antrekan FLAC';
|
||||
|
||||
@override
|
||||
String queueFlacConfirmMessage(int count) {
|
||||
return 'Cari kecocokan online untuk track yang dipilih lalu antrekan download FLAC.\n\nFile yang sudah ada tidak akan diubah atau dihapus.\n\nHanya kecocokan dengan keyakinan tinggi yang akan diantrikan otomatis.\n\n$count dipilih';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueFlacFindingProgress(int current, int total) {
|
||||
return 'Mencari kecocokan FLAC... ($current/$total)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueFlacNoReliableMatches =>
|
||||
'Tidak ada kecocokan online yang cukup meyakinkan untuk pilihan ini';
|
||||
|
||||
@override
|
||||
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
|
||||
return 'Menambahkan $addedCount track ke antrean, melewati $skippedCount';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return 'Failed: $error';
|
||||
@@ -2158,7 +2202,8 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get trackConvertFormat => 'Convert Format';
|
||||
|
||||
@override
|
||||
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||
String get trackConvertFormatSubtitle =>
|
||||
'Konversi ke MP3, Opus, ALAC, atau FLAC';
|
||||
|
||||
@override
|
||||
String get trackConvertTitle => 'Convert Audio';
|
||||
@@ -2181,6 +2226,18 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackConvertConfirmMessageLossless(
|
||||
String sourceFormat,
|
||||
String targetFormat,
|
||||
) {
|
||||
return 'Konversi dari $sourceFormat ke $targetFormat? (Lossless — tanpa kehilangan kualitas)\n\nFile asli akan dihapus setelah konversi.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertLosslessHint =>
|
||||
'Konversi lossless — tanpa kehilangan kualitas';
|
||||
|
||||
@override
|
||||
String get trackConvertConverting => 'Converting audio...';
|
||||
|
||||
@@ -2434,6 +2491,17 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertProgress(int current, int total) {
|
||||
return 'Converting $current of $total...';
|
||||
@@ -2559,9 +2627,6 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
return 'Download $count tracks?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get dialogDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
|
||||
|
||||
@@ -2645,12 +2710,6 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get downloadSelectServiceToEnable =>
|
||||
'Select a built-in service to enable';
|
||||
|
||||
@override
|
||||
String get downloadLossy320 => 'Lossy 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get downloadSelectTidalQobuz =>
|
||||
'Select Tidal or Qobuz above to configure quality';
|
||||
@@ -2727,32 +2786,6 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get downloadMusixmatchAuto => 'Auto';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256Subtitle =>
|
||||
'Best quality Opus, ~8MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||
|
||||
@override
|
||||
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
||||
|
||||
@@ -2769,4 +2802,124 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get cacheRefresh => 'Refresh';
|
||||
|
||||
@override
|
||||
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
trackCount,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
String _temp1 = intl.Intl.pluralLogic(
|
||||
playlistCount,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
|
||||
}
|
||||
|
||||
@override
|
||||
String bulkDownloadPlaylistsButton(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
|
||||
|
||||
@override
|
||||
String get snackbarSelectedPlaylistsEmpty =>
|
||||
'Selected playlists have no tracks';
|
||||
|
||||
@override
|
||||
String playlistsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count playlists',
|
||||
one: '1 playlist',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFill => 'Auto-fill from online';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillDesc =>
|
||||
'Select fields to fill automatically from online metadata';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillFetch => 'Fetch & Fill';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillSearching => 'Searching online...';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoResults =>
|
||||
'No matching metadata found online';
|
||||
|
||||
@override
|
||||
String editMetadataAutoFillDone(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'fields',
|
||||
one: 'field',
|
||||
);
|
||||
return 'Filled $count $_temp0 from online metadata';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoneSelected =>
|
||||
'Select at least one field to auto-fill';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTitle => 'Title';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldArtist => 'Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbum => 'Album';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbumArtist => 'Album Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDate => 'Date';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTrackNum => 'Track #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDiscNum => 'Disc #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldGenre => 'Genre';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldIsrc => 'ISRC';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldLabel => 'Label';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCopyright => 'Copyright';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCover => 'Cover Art';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectAll => 'All';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectEmpty => 'Empty only';
|
||||
}
|
||||
|
||||
@@ -521,6 +521,9 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get dialogImport => 'インポート';
|
||||
|
||||
@override
|
||||
String get dialogDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get dialogDiscard => '破棄';
|
||||
|
||||
@@ -1679,6 +1682,25 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryAutoScan => 'Auto Scan';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanSubtitle =>
|
||||
'Automatically scan your library for new files';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOff => 'Off';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOnOpen => 'Every app open';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanDaily => 'Daily';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanWeekly => 'Weekly';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'アクション';
|
||||
|
||||
@@ -2129,6 +2151,28 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||
|
||||
@override
|
||||
String get queueFlacAction => 'Queue FLAC';
|
||||
|
||||
@override
|
||||
String queueFlacConfirmMessage(int count) {
|
||||
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueFlacFindingProgress(int current, int total) {
|
||||
return 'Finding FLAC matches... ($current/$total)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueFlacNoReliableMatches =>
|
||||
'No reliable online matches found for the selection';
|
||||
|
||||
@override
|
||||
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
|
||||
return 'Added $addedCount tracks to queue, skipped $skippedCount';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return '失敗: $error';
|
||||
@@ -2161,6 +2205,18 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackConvertConfirmMessageLossless(
|
||||
String sourceFormat,
|
||||
String targetFormat,
|
||||
) {
|
||||
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertLosslessHint =>
|
||||
'Lossless conversion — no quality loss';
|
||||
|
||||
@override
|
||||
String get trackConvertConverting => 'オーディオを変換中...';
|
||||
|
||||
@@ -2414,6 +2470,17 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertProgress(int current, int total) {
|
||||
return 'Converting $current of $total...';
|
||||
@@ -2539,9 +2606,6 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
return 'Download $count tracks?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get dialogDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
|
||||
|
||||
@@ -2625,12 +2689,6 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get downloadSelectServiceToEnable =>
|
||||
'Select a built-in service to enable';
|
||||
|
||||
@override
|
||||
String get downloadLossy320 => 'Lossy 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get downloadSelectTidalQobuz =>
|
||||
'Select Tidal or Qobuz above to configure quality';
|
||||
@@ -2707,32 +2765,6 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get downloadMusixmatchAuto => 'Auto';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256Subtitle =>
|
||||
'Best quality Opus, ~8MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||
|
||||
@override
|
||||
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
||||
|
||||
@@ -2749,4 +2781,124 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get cacheRefresh => 'Refresh';
|
||||
|
||||
@override
|
||||
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
trackCount,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
String _temp1 = intl.Intl.pluralLogic(
|
||||
playlistCount,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
|
||||
}
|
||||
|
||||
@override
|
||||
String bulkDownloadPlaylistsButton(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
|
||||
|
||||
@override
|
||||
String get snackbarSelectedPlaylistsEmpty =>
|
||||
'Selected playlists have no tracks';
|
||||
|
||||
@override
|
||||
String playlistsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count playlists',
|
||||
one: '1 playlist',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFill => 'Auto-fill from online';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillDesc =>
|
||||
'Select fields to fill automatically from online metadata';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillFetch => 'Fetch & Fill';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillSearching => 'Searching online...';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoResults =>
|
||||
'No matching metadata found online';
|
||||
|
||||
@override
|
||||
String editMetadataAutoFillDone(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'fields',
|
||||
one: 'field',
|
||||
);
|
||||
return 'Filled $count $_temp0 from online metadata';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoneSelected =>
|
||||
'Select at least one field to auto-fill';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTitle => 'Title';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldArtist => 'Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbum => 'Album';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbumArtist => 'Album Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDate => 'Date';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTrackNum => 'Track #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDiscNum => 'Disc #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldGenre => 'Genre';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldIsrc => 'ISRC';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldLabel => 'Label';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCopyright => 'Copyright';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCover => 'Cover Art';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectAll => 'All';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectEmpty => 'Empty only';
|
||||
}
|
||||
|
||||
@@ -510,6 +510,9 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get dialogImport => '불러오기';
|
||||
|
||||
@override
|
||||
String get dialogDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get dialogDiscard => '취소';
|
||||
|
||||
@@ -1672,6 +1675,25 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryAutoScan => 'Auto Scan';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanSubtitle =>
|
||||
'Automatically scan your library for new files';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOff => 'Off';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOnOpen => 'Every app open';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanDaily => 'Daily';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanWeekly => 'Weekly';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@@ -2122,6 +2144,28 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||
|
||||
@override
|
||||
String get queueFlacAction => 'Queue FLAC';
|
||||
|
||||
@override
|
||||
String queueFlacConfirmMessage(int count) {
|
||||
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueFlacFindingProgress(int current, int total) {
|
||||
return 'Finding FLAC matches... ($current/$total)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueFlacNoReliableMatches =>
|
||||
'No reliable online matches found for the selection';
|
||||
|
||||
@override
|
||||
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
|
||||
return 'Added $addedCount tracks to queue, skipped $skippedCount';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return 'Failed: $error';
|
||||
@@ -2154,6 +2198,18 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackConvertConfirmMessageLossless(
|
||||
String sourceFormat,
|
||||
String targetFormat,
|
||||
) {
|
||||
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertLosslessHint =>
|
||||
'Lossless conversion — no quality loss';
|
||||
|
||||
@override
|
||||
String get trackConvertConverting => 'Converting audio...';
|
||||
|
||||
@@ -2407,6 +2463,17 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertProgress(int current, int total) {
|
||||
return 'Converting $current of $total...';
|
||||
@@ -2532,9 +2599,6 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
return 'Download $count tracks?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get dialogDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
|
||||
|
||||
@@ -2618,12 +2682,6 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get downloadSelectServiceToEnable =>
|
||||
'Select a built-in service to enable';
|
||||
|
||||
@override
|
||||
String get downloadLossy320 => 'Lossy 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get downloadSelectTidalQobuz =>
|
||||
'Select Tidal or Qobuz above to configure quality';
|
||||
@@ -2700,32 +2758,6 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get downloadMusixmatchAuto => 'Auto';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256Subtitle =>
|
||||
'Best quality Opus, ~8MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||
|
||||
@override
|
||||
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
||||
|
||||
@@ -2742,4 +2774,124 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get cacheRefresh => 'Refresh';
|
||||
|
||||
@override
|
||||
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
trackCount,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
String _temp1 = intl.Intl.pluralLogic(
|
||||
playlistCount,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
|
||||
}
|
||||
|
||||
@override
|
||||
String bulkDownloadPlaylistsButton(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
|
||||
|
||||
@override
|
||||
String get snackbarSelectedPlaylistsEmpty =>
|
||||
'Selected playlists have no tracks';
|
||||
|
||||
@override
|
||||
String playlistsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count playlists',
|
||||
one: '1 playlist',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFill => 'Auto-fill from online';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillDesc =>
|
||||
'Select fields to fill automatically from online metadata';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillFetch => 'Fetch & Fill';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillSearching => 'Searching online...';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoResults =>
|
||||
'No matching metadata found online';
|
||||
|
||||
@override
|
||||
String editMetadataAutoFillDone(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'fields',
|
||||
one: 'field',
|
||||
);
|
||||
return 'Filled $count $_temp0 from online metadata';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoneSelected =>
|
||||
'Select at least one field to auto-fill';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTitle => 'Title';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldArtist => 'Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbum => 'Album';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbumArtist => 'Album Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDate => 'Date';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTrackNum => 'Track #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDiscNum => 'Disc #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldGenre => 'Genre';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldIsrc => 'ISRC';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldLabel => 'Label';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCopyright => 'Copyright';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCover => 'Cover Art';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectAll => 'All';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectEmpty => 'Empty only';
|
||||
}
|
||||
|
||||
@@ -525,6 +525,9 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get dialogImport => 'Import';
|
||||
|
||||
@override
|
||||
String get dialogDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get dialogDiscard => 'Discard';
|
||||
|
||||
@@ -1692,6 +1695,25 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryAutoScan => 'Auto Scan';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanSubtitle =>
|
||||
'Automatically scan your library for new files';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOff => 'Off';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOnOpen => 'Every app open';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanDaily => 'Daily';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanWeekly => 'Weekly';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@@ -2142,6 +2164,28 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||
|
||||
@override
|
||||
String get queueFlacAction => 'Queue FLAC';
|
||||
|
||||
@override
|
||||
String queueFlacConfirmMessage(int count) {
|
||||
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueFlacFindingProgress(int current, int total) {
|
||||
return 'Finding FLAC matches... ($current/$total)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueFlacNoReliableMatches =>
|
||||
'No reliable online matches found for the selection';
|
||||
|
||||
@override
|
||||
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
|
||||
return 'Added $addedCount tracks to queue, skipped $skippedCount';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return 'Failed: $error';
|
||||
@@ -2174,6 +2218,18 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackConvertConfirmMessageLossless(
|
||||
String sourceFormat,
|
||||
String targetFormat,
|
||||
) {
|
||||
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertLosslessHint =>
|
||||
'Lossless conversion — no quality loss';
|
||||
|
||||
@override
|
||||
String get trackConvertConverting => 'Converting audio...';
|
||||
|
||||
@@ -2427,6 +2483,17 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertProgress(int current, int total) {
|
||||
return 'Converting $current of $total...';
|
||||
@@ -2552,9 +2619,6 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
return 'Download $count tracks?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get dialogDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
|
||||
|
||||
@@ -2638,12 +2702,6 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get downloadSelectServiceToEnable =>
|
||||
'Select a built-in service to enable';
|
||||
|
||||
@override
|
||||
String get downloadLossy320 => 'Lossy 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get downloadSelectTidalQobuz =>
|
||||
'Select Tidal or Qobuz above to configure quality';
|
||||
@@ -2720,32 +2778,6 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get downloadMusixmatchAuto => 'Auto';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256Subtitle =>
|
||||
'Best quality Opus, ~8MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||
|
||||
@override
|
||||
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
||||
|
||||
@@ -2762,4 +2794,124 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get cacheRefresh => 'Refresh';
|
||||
|
||||
@override
|
||||
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
trackCount,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
String _temp1 = intl.Intl.pluralLogic(
|
||||
playlistCount,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
|
||||
}
|
||||
|
||||
@override
|
||||
String bulkDownloadPlaylistsButton(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
|
||||
|
||||
@override
|
||||
String get snackbarSelectedPlaylistsEmpty =>
|
||||
'Selected playlists have no tracks';
|
||||
|
||||
@override
|
||||
String playlistsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count playlists',
|
||||
one: '1 playlist',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFill => 'Auto-fill from online';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillDesc =>
|
||||
'Select fields to fill automatically from online metadata';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillFetch => 'Fetch & Fill';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillSearching => 'Searching online...';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoResults =>
|
||||
'No matching metadata found online';
|
||||
|
||||
@override
|
||||
String editMetadataAutoFillDone(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'fields',
|
||||
one: 'field',
|
||||
);
|
||||
return 'Filled $count $_temp0 from online metadata';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoneSelected =>
|
||||
'Select at least one field to auto-fill';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTitle => 'Title';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldArtist => 'Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbum => 'Album';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbumArtist => 'Album Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDate => 'Date';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTrackNum => 'Track #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDiscNum => 'Disc #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldGenre => 'Genre';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldIsrc => 'ISRC';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldLabel => 'Label';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCopyright => 'Copyright';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCover => 'Cover Art';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectAll => 'All';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectEmpty => 'Empty only';
|
||||
}
|
||||
|
||||
@@ -525,6 +525,9 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get dialogImport => 'Import';
|
||||
|
||||
@override
|
||||
String get dialogDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get dialogDiscard => 'Discard';
|
||||
|
||||
@@ -1692,6 +1695,25 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryAutoScan => 'Auto Scan';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanSubtitle =>
|
||||
'Automatically scan your library for new files';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOff => 'Off';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOnOpen => 'Every app open';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanDaily => 'Daily';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanWeekly => 'Weekly';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@@ -2142,6 +2164,28 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||
|
||||
@override
|
||||
String get queueFlacAction => 'Queue FLAC';
|
||||
|
||||
@override
|
||||
String queueFlacConfirmMessage(int count) {
|
||||
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueFlacFindingProgress(int current, int total) {
|
||||
return 'Finding FLAC matches... ($current/$total)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueFlacNoReliableMatches =>
|
||||
'No reliable online matches found for the selection';
|
||||
|
||||
@override
|
||||
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
|
||||
return 'Added $addedCount tracks to queue, skipped $skippedCount';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return 'Failed: $error';
|
||||
@@ -2151,7 +2195,8 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
String get trackConvertFormat => 'Convert Format';
|
||||
|
||||
@override
|
||||
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||
String get trackConvertFormatSubtitle =>
|
||||
'Convert to MP3, Opus, ALAC, or FLAC';
|
||||
|
||||
@override
|
||||
String get trackConvertTitle => 'Convert Audio';
|
||||
@@ -2174,6 +2219,18 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackConvertConfirmMessageLossless(
|
||||
String sourceFormat,
|
||||
String targetFormat,
|
||||
) {
|
||||
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertLosslessHint =>
|
||||
'Lossless conversion — no quality loss';
|
||||
|
||||
@override
|
||||
String get trackConvertConverting => 'Converting audio...';
|
||||
|
||||
@@ -2427,6 +2484,17 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertProgress(int current, int total) {
|
||||
return 'Converting $current of $total...';
|
||||
@@ -2552,9 +2620,6 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
return 'Download $count tracks?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get dialogDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
|
||||
|
||||
@@ -2638,12 +2703,6 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
String get downloadSelectServiceToEnable =>
|
||||
'Select a built-in service to enable';
|
||||
|
||||
@override
|
||||
String get downloadLossy320 => 'Lossy 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get downloadSelectTidalQobuz =>
|
||||
'Select Tidal or Qobuz above to configure quality';
|
||||
@@ -2720,32 +2779,6 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get downloadMusixmatchAuto => 'Auto';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256Subtitle =>
|
||||
'Best quality Opus, ~8MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||
|
||||
@override
|
||||
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
||||
|
||||
@@ -2762,6 +2795,126 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get cacheRefresh => 'Refresh';
|
||||
|
||||
@override
|
||||
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
trackCount,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
String _temp1 = intl.Intl.pluralLogic(
|
||||
playlistCount,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
|
||||
}
|
||||
|
||||
@override
|
||||
String bulkDownloadPlaylistsButton(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
|
||||
|
||||
@override
|
||||
String get snackbarSelectedPlaylistsEmpty =>
|
||||
'Selected playlists have no tracks';
|
||||
|
||||
@override
|
||||
String playlistsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count playlists',
|
||||
one: '1 playlist',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFill => 'Auto-fill from online';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillDesc =>
|
||||
'Select fields to fill automatically from online metadata';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillFetch => 'Fetch & Fill';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillSearching => 'Searching online...';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoResults =>
|
||||
'No matching metadata found online';
|
||||
|
||||
@override
|
||||
String editMetadataAutoFillDone(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'fields',
|
||||
one: 'field',
|
||||
);
|
||||
return 'Filled $count $_temp0 from online metadata';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoneSelected =>
|
||||
'Select at least one field to auto-fill';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTitle => 'Title';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldArtist => 'Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbum => 'Album';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbumArtist => 'Album Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDate => 'Date';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTrackNum => 'Track #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDiscNum => 'Disc #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldGenre => 'Genre';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldIsrc => 'ISRC';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldLabel => 'Label';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCopyright => 'Copyright';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCover => 'Cover Art';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectAll => 'All';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectEmpty => 'Empty only';
|
||||
}
|
||||
|
||||
/// The translations for Portuguese, as used in Portugal (`pt_PT`).
|
||||
|
||||
@@ -534,6 +534,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get dialogImport => 'Импорт';
|
||||
|
||||
@override
|
||||
String get dialogDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get dialogDiscard => 'Отменить';
|
||||
|
||||
@@ -1728,6 +1731,25 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Показать при поиске существующих треков';
|
||||
|
||||
@override
|
||||
String get libraryAutoScan => 'Auto Scan';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanSubtitle =>
|
||||
'Automatically scan your library for new files';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOff => 'Off';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOnOpen => 'Every app open';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanDaily => 'Daily';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanWeekly => 'Weekly';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Действия';
|
||||
|
||||
@@ -2195,6 +2217,28 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get trackReEnrichFfmpegFailed =>
|
||||
'Ошибка встраивания метаданных FFmpeg';
|
||||
|
||||
@override
|
||||
String get queueFlacAction => 'Queue FLAC';
|
||||
|
||||
@override
|
||||
String queueFlacConfirmMessage(int count) {
|
||||
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueFlacFindingProgress(int current, int total) {
|
||||
return 'Finding FLAC matches... ($current/$total)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueFlacNoReliableMatches =>
|
||||
'No reliable online matches found for the selection';
|
||||
|
||||
@override
|
||||
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
|
||||
return 'Added $addedCount tracks to queue, skipped $skippedCount';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return 'Ошибка: $error';
|
||||
@@ -2227,6 +2271,18 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
return 'Конвертировать из $sourceFormat в $targetFormat $bitrate?\n\nОригинальный файл будет удален после конвертации.';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackConvertConfirmMessageLossless(
|
||||
String sourceFormat,
|
||||
String targetFormat,
|
||||
) {
|
||||
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertLosslessHint =>
|
||||
'Lossless conversion — no quality loss';
|
||||
|
||||
@override
|
||||
String get trackConvertConverting => 'Конвертация аудио...';
|
||||
|
||||
@@ -2486,6 +2542,17 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertProgress(int current, int total) {
|
||||
return 'Конвертация $current из $total...';
|
||||
@@ -2611,9 +2678,6 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
return 'Download $count tracks?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get dialogDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
|
||||
|
||||
@@ -2697,12 +2761,6 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get downloadSelectServiceToEnable =>
|
||||
'Select a built-in service to enable';
|
||||
|
||||
@override
|
||||
String get downloadLossy320 => 'Lossy 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get downloadSelectTidalQobuz =>
|
||||
'Select Tidal or Qobuz above to configure quality';
|
||||
@@ -2779,32 +2837,6 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get downloadMusixmatchAuto => 'Auto';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256Subtitle =>
|
||||
'Best quality Opus, ~8MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||
|
||||
@override
|
||||
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
||||
|
||||
@@ -2821,4 +2853,124 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get cacheRefresh => 'Refresh';
|
||||
|
||||
@override
|
||||
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
trackCount,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
String _temp1 = intl.Intl.pluralLogic(
|
||||
playlistCount,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
|
||||
}
|
||||
|
||||
@override
|
||||
String bulkDownloadPlaylistsButton(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
|
||||
|
||||
@override
|
||||
String get snackbarSelectedPlaylistsEmpty =>
|
||||
'Selected playlists have no tracks';
|
||||
|
||||
@override
|
||||
String playlistsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count playlists',
|
||||
one: '1 playlist',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFill => 'Auto-fill from online';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillDesc =>
|
||||
'Select fields to fill automatically from online metadata';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillFetch => 'Fetch & Fill';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillSearching => 'Searching online...';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoResults =>
|
||||
'No matching metadata found online';
|
||||
|
||||
@override
|
||||
String editMetadataAutoFillDone(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'fields',
|
||||
one: 'field',
|
||||
);
|
||||
return 'Filled $count $_temp0 from online metadata';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoneSelected =>
|
||||
'Select at least one field to auto-fill';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTitle => 'Title';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldArtist => 'Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbum => 'Album';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbumArtist => 'Album Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDate => 'Date';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTrackNum => 'Track #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDiscNum => 'Disc #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldGenre => 'Genre';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldIsrc => 'ISRC';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldLabel => 'Label';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCopyright => 'Copyright';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCover => 'Cover Art';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectAll => 'All';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectEmpty => 'Empty only';
|
||||
}
|
||||
|
||||
@@ -530,6 +530,9 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
@override
|
||||
String get dialogImport => 'İçe aktar';
|
||||
|
||||
@override
|
||||
String get dialogDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get dialogDiscard => 'Vazgeç';
|
||||
|
||||
@@ -1704,6 +1707,25 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryAutoScan => 'Auto Scan';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanSubtitle =>
|
||||
'Automatically scan your library for new files';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOff => 'Off';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOnOpen => 'Every app open';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanDaily => 'Daily';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanWeekly => 'Weekly';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@@ -2154,6 +2176,28 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
@override
|
||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||
|
||||
@override
|
||||
String get queueFlacAction => 'Queue FLAC';
|
||||
|
||||
@override
|
||||
String queueFlacConfirmMessage(int count) {
|
||||
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueFlacFindingProgress(int current, int total) {
|
||||
return 'Finding FLAC matches... ($current/$total)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueFlacNoReliableMatches =>
|
||||
'No reliable online matches found for the selection';
|
||||
|
||||
@override
|
||||
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
|
||||
return 'Added $addedCount tracks to queue, skipped $skippedCount';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return 'Failed: $error';
|
||||
@@ -2186,6 +2230,18 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackConvertConfirmMessageLossless(
|
||||
String sourceFormat,
|
||||
String targetFormat,
|
||||
) {
|
||||
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertLosslessHint =>
|
||||
'Lossless conversion — no quality loss';
|
||||
|
||||
@override
|
||||
String get trackConvertConverting => 'Converting audio...';
|
||||
|
||||
@@ -2439,6 +2495,17 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertProgress(int current, int total) {
|
||||
return 'Converting $current of $total...';
|
||||
@@ -2564,9 +2631,6 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
return 'Download $count tracks?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get dialogDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
|
||||
|
||||
@@ -2650,12 +2714,6 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
String get downloadSelectServiceToEnable =>
|
||||
'Select a built-in service to enable';
|
||||
|
||||
@override
|
||||
String get downloadLossy320 => 'Lossy 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get downloadSelectTidalQobuz =>
|
||||
'Select Tidal or Qobuz above to configure quality';
|
||||
@@ -2732,32 +2790,6 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
@override
|
||||
String get downloadMusixmatchAuto => 'Auto';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256Subtitle =>
|
||||
'Best quality Opus, ~8MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||
|
||||
@override
|
||||
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
||||
|
||||
@@ -2774,4 +2806,124 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get cacheRefresh => 'Refresh';
|
||||
|
||||
@override
|
||||
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
trackCount,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
String _temp1 = intl.Intl.pluralLogic(
|
||||
playlistCount,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
|
||||
}
|
||||
|
||||
@override
|
||||
String bulkDownloadPlaylistsButton(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
|
||||
|
||||
@override
|
||||
String get snackbarSelectedPlaylistsEmpty =>
|
||||
'Selected playlists have no tracks';
|
||||
|
||||
@override
|
||||
String playlistsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count playlists',
|
||||
one: '1 playlist',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFill => 'Auto-fill from online';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillDesc =>
|
||||
'Select fields to fill automatically from online metadata';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillFetch => 'Fetch & Fill';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillSearching => 'Searching online...';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoResults =>
|
||||
'No matching metadata found online';
|
||||
|
||||
@override
|
||||
String editMetadataAutoFillDone(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'fields',
|
||||
one: 'field',
|
||||
);
|
||||
return 'Filled $count $_temp0 from online metadata';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoneSelected =>
|
||||
'Select at least one field to auto-fill';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTitle => 'Title';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldArtist => 'Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbum => 'Album';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbumArtist => 'Album Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDate => 'Date';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTrackNum => 'Track #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDiscNum => 'Disc #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldGenre => 'Genre';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldIsrc => 'ISRC';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldLabel => 'Label';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCopyright => 'Copyright';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCover => 'Cover Art';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectAll => 'All';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectEmpty => 'Empty only';
|
||||
}
|
||||
|
||||
@@ -525,6 +525,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get dialogImport => 'Import';
|
||||
|
||||
@override
|
||||
String get dialogDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get dialogDiscard => 'Discard';
|
||||
|
||||
@@ -1692,6 +1695,25 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryAutoScan => 'Auto Scan';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanSubtitle =>
|
||||
'Automatically scan your library for new files';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOff => 'Off';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOnOpen => 'Every app open';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanDaily => 'Daily';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanWeekly => 'Weekly';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@@ -2142,6 +2164,28 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||
|
||||
@override
|
||||
String get queueFlacAction => 'Queue FLAC';
|
||||
|
||||
@override
|
||||
String queueFlacConfirmMessage(int count) {
|
||||
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueFlacFindingProgress(int current, int total) {
|
||||
return 'Finding FLAC matches... ($current/$total)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueFlacNoReliableMatches =>
|
||||
'No reliable online matches found for the selection';
|
||||
|
||||
@override
|
||||
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
|
||||
return 'Added $addedCount tracks to queue, skipped $skippedCount';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return 'Failed: $error';
|
||||
@@ -2151,7 +2195,8 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get trackConvertFormat => 'Convert Format';
|
||||
|
||||
@override
|
||||
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||
String get trackConvertFormatSubtitle =>
|
||||
'Convert to MP3, Opus, ALAC, or FLAC';
|
||||
|
||||
@override
|
||||
String get trackConvertTitle => 'Convert Audio';
|
||||
@@ -2174,6 +2219,18 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackConvertConfirmMessageLossless(
|
||||
String sourceFormat,
|
||||
String targetFormat,
|
||||
) {
|
||||
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertLosslessHint =>
|
||||
'Lossless conversion — no quality loss';
|
||||
|
||||
@override
|
||||
String get trackConvertConverting => 'Converting audio...';
|
||||
|
||||
@@ -2427,6 +2484,17 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertProgress(int current, int total) {
|
||||
return 'Converting $current of $total...';
|
||||
@@ -2552,9 +2620,6 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
return 'Download $count tracks?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get dialogDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
|
||||
|
||||
@@ -2638,12 +2703,6 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get downloadSelectServiceToEnable =>
|
||||
'Select a built-in service to enable';
|
||||
|
||||
@override
|
||||
String get downloadLossy320 => 'Lossy 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get downloadSelectTidalQobuz =>
|
||||
'Select Tidal or Qobuz above to configure quality';
|
||||
@@ -2720,32 +2779,6 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get downloadMusixmatchAuto => 'Auto';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256Subtitle =>
|
||||
'Best quality Opus, ~8MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||
|
||||
@override
|
||||
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
||||
|
||||
@@ -2762,6 +2795,126 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get cacheRefresh => 'Refresh';
|
||||
|
||||
@override
|
||||
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
trackCount,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
String _temp1 = intl.Intl.pluralLogic(
|
||||
playlistCount,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
|
||||
}
|
||||
|
||||
@override
|
||||
String bulkDownloadPlaylistsButton(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
|
||||
|
||||
@override
|
||||
String get snackbarSelectedPlaylistsEmpty =>
|
||||
'Selected playlists have no tracks';
|
||||
|
||||
@override
|
||||
String playlistsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count playlists',
|
||||
one: '1 playlist',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFill => 'Auto-fill from online';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillDesc =>
|
||||
'Select fields to fill automatically from online metadata';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillFetch => 'Fetch & Fill';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillSearching => 'Searching online...';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoResults =>
|
||||
'No matching metadata found online';
|
||||
|
||||
@override
|
||||
String editMetadataAutoFillDone(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'fields',
|
||||
one: 'field',
|
||||
);
|
||||
return 'Filled $count $_temp0 from online metadata';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoneSelected =>
|
||||
'Select at least one field to auto-fill';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTitle => 'Title';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldArtist => 'Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbum => 'Album';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbumArtist => 'Album Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDate => 'Date';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTrackNum => 'Track #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDiscNum => 'Disc #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldGenre => 'Genre';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldIsrc => 'ISRC';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldLabel => 'Label';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCopyright => 'Copyright';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCover => 'Cover Art';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectAll => 'All';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectEmpty => 'Empty only';
|
||||
}
|
||||
|
||||
/// The translations for Chinese, as used in China (`zh_CN`).
|
||||
|
||||
+231
-41
@@ -671,6 +671,10 @@
|
||||
"@dialogImport": {
|
||||
"description": "Dialog button - import data"
|
||||
},
|
||||
"dialogDownload": "Download",
|
||||
"@dialogDownload": {
|
||||
"description": "Dialog button - download action"
|
||||
},
|
||||
"dialogDiscard": "Discard",
|
||||
"@dialogDiscard": {
|
||||
"description": "Dialog button - discard changes"
|
||||
@@ -2238,6 +2242,30 @@
|
||||
"@libraryShowDuplicateIndicatorSubtitle": {
|
||||
"description": "Subtitle for duplicate indicator toggle"
|
||||
},
|
||||
"libraryAutoScan": "Auto Scan",
|
||||
"@libraryAutoScan": {
|
||||
"description": "Setting for automatic library scanning"
|
||||
},
|
||||
"libraryAutoScanSubtitle": "Automatically scan your library for new files",
|
||||
"@libraryAutoScanSubtitle": {
|
||||
"description": "Subtitle for auto scan setting"
|
||||
},
|
||||
"libraryAutoScanOff": "Off",
|
||||
"@libraryAutoScanOff": {
|
||||
"description": "Auto scan disabled"
|
||||
},
|
||||
"libraryAutoScanOnOpen": "Every app open",
|
||||
"@libraryAutoScanOnOpen": {
|
||||
"description": "Auto scan when app opens"
|
||||
},
|
||||
"libraryAutoScanDaily": "Daily",
|
||||
"@libraryAutoScanDaily": {
|
||||
"description": "Auto scan once per day"
|
||||
},
|
||||
"libraryAutoScanWeekly": "Weekly",
|
||||
"@libraryAutoScanWeekly": {
|
||||
"description": "Auto scan once per week"
|
||||
},
|
||||
"libraryActions": "Actions",
|
||||
"@libraryActions": {
|
||||
"description": "Section header for library actions"
|
||||
@@ -2815,6 +2843,47 @@
|
||||
"@trackReEnrichFfmpegFailed": {
|
||||
"description": "Snackbar when FFmpeg embed fails for MP3/Opus"
|
||||
},
|
||||
"queueFlacAction": "Queue FLAC",
|
||||
"@queueFlacAction": {
|
||||
"description": "Action/button label for queueing FLAC redownloads for local tracks"
|
||||
},
|
||||
"queueFlacConfirmMessage": "Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n{count} selected",
|
||||
"@queueFlacConfirmMessage": {
|
||||
"description": "Confirmation dialog body before queueing FLAC redownloads for local tracks",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"queueFlacFindingProgress": "Finding FLAC matches... ({current}/{total})",
|
||||
"@queueFlacFindingProgress": {
|
||||
"description": "Snackbar while resolving remote matches for local FLAC redownloads",
|
||||
"placeholders": {
|
||||
"current": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"queueFlacNoReliableMatches": "No reliable online matches found for the selection",
|
||||
"@queueFlacNoReliableMatches": {
|
||||
"description": "Snackbar when no safe FLAC redownload matches were found"
|
||||
},
|
||||
"queueFlacQueuedWithSkipped": "Added {addedCount} tracks to queue, skipped {skippedCount}",
|
||||
"@queueFlacQueuedWithSkipped": {
|
||||
"description": "Snackbar when some selected local tracks were queued for FLAC redownload and some were skipped",
|
||||
"placeholders": {
|
||||
"addedCount": {
|
||||
"type": "int"
|
||||
},
|
||||
"skippedCount": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"trackSaveFailed": "Failed: {error}",
|
||||
"@trackSaveFailed": {
|
||||
"description": "Snackbar when save operation fails",
|
||||
@@ -2828,7 +2897,7 @@
|
||||
"@trackConvertFormat": {
|
||||
"description": "Menu item - convert audio format"
|
||||
},
|
||||
"trackConvertFormatSubtitle": "Convert to MP3 or Opus",
|
||||
"trackConvertFormatSubtitle": "Convert to MP3, Opus, ALAC, or FLAC",
|
||||
"@trackConvertFormatSubtitle": {
|
||||
"description": "Subtitle for convert format menu item"
|
||||
},
|
||||
@@ -2863,6 +2932,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"trackConvertConfirmMessageLossless": "Convert from {sourceFormat} to {targetFormat}? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.",
|
||||
"@trackConvertConfirmMessageLossless": {
|
||||
"description": "Confirmation dialog message for lossless-to-lossless conversion",
|
||||
"placeholders": {
|
||||
"sourceFormat": {
|
||||
"type": "String"
|
||||
},
|
||||
"targetFormat": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"trackConvertLosslessHint": "Lossless conversion — no quality loss",
|
||||
"@trackConvertLosslessHint": {
|
||||
"description": "Hint shown when converting between lossless formats"
|
||||
},
|
||||
"trackConvertConverting": "Converting audio...",
|
||||
"@trackConvertConverting": {
|
||||
"description": "Snackbar while converting"
|
||||
@@ -3214,6 +3299,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectionBatchConvertConfirmMessageLossless": "Convert {count} {count, plural, =1{track} other{tracks}} to {format}? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.",
|
||||
"@selectionBatchConvertConfirmMessageLossless": {
|
||||
"description": "Confirmation dialog message for lossless batch conversion",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
},
|
||||
"format": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectionBatchConvertProgress": "Converting {current} of {total}...",
|
||||
"@selectionBatchConvertProgress": {
|
||||
"description": "Snackbar during batch conversion progress",
|
||||
@@ -3509,14 +3606,7 @@
|
||||
"@downloadSelectServiceToEnable": {
|
||||
"description": "Hint shown instead of Ask-quality subtitle when no built-in service selected"
|
||||
},
|
||||
"downloadLossy320": "Lossy 320kbps",
|
||||
"@downloadLossy320": {
|
||||
"description": "Quality option label for Tidal lossy 320kbps"
|
||||
},
|
||||
"downloadLossyFormat": "Lossy Format",
|
||||
"@downloadLossyFormat": {
|
||||
"description": "Setting title to pick output format for Tidal lossy downloads"
|
||||
},
|
||||
|
||||
"downloadSelectTidalQobuz": "Select Tidal or Qobuz above to configure quality",
|
||||
"@downloadSelectTidalQobuz": {
|
||||
"description": "Info hint when non-Tidal/Qobuz service is selected"
|
||||
@@ -3602,38 +3692,7 @@
|
||||
"@downloadMusixmatchAuto": {
|
||||
"description": "Button to reset Musixmatch language to automatic"
|
||||
},
|
||||
"downloadLossy320Format": "Lossy 320kbps Format",
|
||||
"@downloadLossy320Format": {
|
||||
"description": "Title of the Tidal lossy format picker bottom sheet"
|
||||
},
|
||||
"downloadLossy320FormatDesc": "Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.",
|
||||
"@downloadLossy320FormatDesc": {
|
||||
"description": "Description in the Tidal lossy format picker"
|
||||
},
|
||||
"downloadLossyMp3": "MP3 320kbps",
|
||||
"@downloadLossyMp3": {
|
||||
"description": "Tidal lossy format option - MP3 320kbps"
|
||||
},
|
||||
"downloadLossyMp3Subtitle": "Best compatibility, ~10MB per track",
|
||||
"@downloadLossyMp3Subtitle": {
|
||||
"description": "Subtitle for MP3 320kbps option"
|
||||
},
|
||||
"downloadLossyOpus256": "Opus 256kbps",
|
||||
"@downloadLossyOpus256": {
|
||||
"description": "Tidal lossy format option - Opus 256kbps"
|
||||
},
|
||||
"downloadLossyOpus256Subtitle": "Best quality Opus, ~8MB per track",
|
||||
"@downloadLossyOpus256Subtitle": {
|
||||
"description": "Subtitle for Opus 256kbps option"
|
||||
},
|
||||
"downloadLossyOpus128": "Opus 128kbps",
|
||||
"@downloadLossyOpus128": {
|
||||
"description": "Tidal lossy format option - Opus 128kbps"
|
||||
},
|
||||
"downloadLossyOpus128Subtitle": "Smallest size, ~4MB per track",
|
||||
"@downloadLossyOpus128Subtitle": {
|
||||
"description": "Subtitle for Opus 128kbps option"
|
||||
},
|
||||
|
||||
"downloadNetworkAnySubtitle": "WiFi + Mobile Data",
|
||||
"@downloadNetworkAnySubtitle": {
|
||||
"description": "Subtitle for 'Any' network mode option"
|
||||
@@ -3657,5 +3716,136 @@
|
||||
"cacheRefresh": "Refresh",
|
||||
"@cacheRefresh": {
|
||||
"description": "Tooltip for refresh button on cache management page"
|
||||
},
|
||||
"dialogDownloadAllTitle": "Download All",
|
||||
"@dialogDownloadAllTitle": {
|
||||
"description": "Dialog title for bulk download confirmation"
|
||||
},
|
||||
"dialogDownloadPlaylistsMessage": "Download {trackCount} {trackCount, plural, =1{track} other{tracks}} from {playlistCount} {playlistCount, plural, =1{playlist} other{playlists}}?",
|
||||
"@dialogDownloadPlaylistsMessage": {
|
||||
"description": "Dialog message for bulk playlist download confirmation",
|
||||
"placeholders": {
|
||||
"trackCount": {
|
||||
"type": "int"
|
||||
},
|
||||
"playlistCount": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"bulkDownloadPlaylistsButton": "Download {count} {count, plural, =1{playlist} other{playlists}}",
|
||||
"@bulkDownloadPlaylistsButton": {
|
||||
"description": "Button label for bulk downloading selected playlists",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"bulkDownloadSelectPlaylists": "Select playlists to download",
|
||||
"@bulkDownloadSelectPlaylists": {
|
||||
"description": "Button label when no playlists are selected for download"
|
||||
},
|
||||
"snackbarSelectedPlaylistsEmpty": "Selected playlists have no tracks",
|
||||
"@snackbarSelectedPlaylistsEmpty": {
|
||||
"description": "Snackbar when selected playlists contain no tracks"
|
||||
},
|
||||
"playlistsCount": "{count, plural, =1{1 playlist} other{{count} playlists}}",
|
||||
"@playlistsCount": {
|
||||
"description": "Playlist count display",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"editMetadataAutoFill": "Auto-fill from online",
|
||||
"@editMetadataAutoFill": {
|
||||
"description": "Section title for selective online metadata auto-fill in the edit metadata sheet"
|
||||
},
|
||||
"editMetadataAutoFillDesc": "Select fields to fill automatically from online metadata",
|
||||
"@editMetadataAutoFillDesc": {
|
||||
"description": "Description for the auto-fill section"
|
||||
},
|
||||
"editMetadataAutoFillFetch": "Fetch & Fill",
|
||||
"@editMetadataAutoFillFetch": {
|
||||
"description": "Button label to fetch online metadata and fill selected fields"
|
||||
},
|
||||
"editMetadataAutoFillSearching": "Searching online...",
|
||||
"@editMetadataAutoFillSearching": {
|
||||
"description": "Snackbar shown while searching for online metadata"
|
||||
},
|
||||
"editMetadataAutoFillNoResults": "No matching metadata found online",
|
||||
"@editMetadataAutoFillNoResults": {
|
||||
"description": "Snackbar when online metadata search returns no results"
|
||||
},
|
||||
"editMetadataAutoFillDone": "Filled {count} {count, plural, =1{field} other{fields}} from online metadata",
|
||||
"@editMetadataAutoFillDone": {
|
||||
"description": "Snackbar confirming how many fields were auto-filled",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"editMetadataAutoFillNoneSelected": "Select at least one field to auto-fill",
|
||||
"@editMetadataAutoFillNoneSelected": {
|
||||
"description": "Snackbar when user taps Fetch without selecting any fields"
|
||||
},
|
||||
"editMetadataFieldTitle": "Title",
|
||||
"@editMetadataFieldTitle": {
|
||||
"description": "Chip label for title field in auto-fill selector"
|
||||
},
|
||||
"editMetadataFieldArtist": "Artist",
|
||||
"@editMetadataFieldArtist": {
|
||||
"description": "Chip label for artist field in auto-fill selector"
|
||||
},
|
||||
"editMetadataFieldAlbum": "Album",
|
||||
"@editMetadataFieldAlbum": {
|
||||
"description": "Chip label for album field in auto-fill selector"
|
||||
},
|
||||
"editMetadataFieldAlbumArtist": "Album Artist",
|
||||
"@editMetadataFieldAlbumArtist": {
|
||||
"description": "Chip label for album artist field in auto-fill selector"
|
||||
},
|
||||
"editMetadataFieldDate": "Date",
|
||||
"@editMetadataFieldDate": {
|
||||
"description": "Chip label for date field in auto-fill selector"
|
||||
},
|
||||
"editMetadataFieldTrackNum": "Track #",
|
||||
"@editMetadataFieldTrackNum": {
|
||||
"description": "Chip label for track number field in auto-fill selector"
|
||||
},
|
||||
"editMetadataFieldDiscNum": "Disc #",
|
||||
"@editMetadataFieldDiscNum": {
|
||||
"description": "Chip label for disc number field in auto-fill selector"
|
||||
},
|
||||
"editMetadataFieldGenre": "Genre",
|
||||
"@editMetadataFieldGenre": {
|
||||
"description": "Chip label for genre field in auto-fill selector"
|
||||
},
|
||||
"editMetadataFieldIsrc": "ISRC",
|
||||
"@editMetadataFieldIsrc": {
|
||||
"description": "Chip label for ISRC field in auto-fill selector"
|
||||
},
|
||||
"editMetadataFieldLabel": "Label",
|
||||
"@editMetadataFieldLabel": {
|
||||
"description": "Chip label for label field in auto-fill selector"
|
||||
},
|
||||
"editMetadataFieldCopyright": "Copyright",
|
||||
"@editMetadataFieldCopyright": {
|
||||
"description": "Chip label for copyright field in auto-fill selector"
|
||||
},
|
||||
"editMetadataFieldCover": "Cover Art",
|
||||
"@editMetadataFieldCover": {
|
||||
"description": "Chip label for cover art field in auto-fill selector"
|
||||
},
|
||||
"editMetadataSelectAll": "All",
|
||||
"@editMetadataSelectAll": {
|
||||
"description": "Button to select all fields for auto-fill"
|
||||
},
|
||||
"editMetadataSelectEmpty": "Empty only",
|
||||
"@editMetadataSelectEmpty": {
|
||||
"description": "Button to select only fields that are currently empty"
|
||||
}
|
||||
}
|
||||
|
||||
+59
-2
@@ -2755,6 +2755,47 @@
|
||||
"@trackReEnrichFfmpegFailed": {
|
||||
"description": "Snackbar when FFmpeg embed fails for MP3/Opus"
|
||||
},
|
||||
"queueFlacAction": "Antrekan FLAC",
|
||||
"@queueFlacAction": {
|
||||
"description": "Action/button label for queueing FLAC redownloads for local tracks"
|
||||
},
|
||||
"queueFlacConfirmMessage": "Cari kecocokan online untuk track yang dipilih lalu antrekan download FLAC.\n\nFile yang sudah ada tidak akan diubah atau dihapus.\n\nHanya kecocokan dengan keyakinan tinggi yang akan diantrikan otomatis.\n\n{count} dipilih",
|
||||
"@queueFlacConfirmMessage": {
|
||||
"description": "Confirmation dialog body before queueing FLAC redownloads for local tracks",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"queueFlacFindingProgress": "Mencari kecocokan FLAC... ({current}/{total})",
|
||||
"@queueFlacFindingProgress": {
|
||||
"description": "Snackbar while resolving remote matches for local FLAC redownloads",
|
||||
"placeholders": {
|
||||
"current": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"queueFlacNoReliableMatches": "Tidak ada kecocokan online yang cukup meyakinkan untuk pilihan ini",
|
||||
"@queueFlacNoReliableMatches": {
|
||||
"description": "Snackbar when no safe FLAC redownload matches were found"
|
||||
},
|
||||
"queueFlacQueuedWithSkipped": "Menambahkan {addedCount} track ke antrean, melewati {skippedCount}",
|
||||
"@queueFlacQueuedWithSkipped": {
|
||||
"description": "Snackbar when some selected local tracks were queued for FLAC redownload and some were skipped",
|
||||
"placeholders": {
|
||||
"addedCount": {
|
||||
"type": "int"
|
||||
},
|
||||
"skippedCount": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"trackSaveFailed": "Failed: {error}",
|
||||
"@trackSaveFailed": {
|
||||
"description": "Snackbar when save operation fails",
|
||||
@@ -2768,7 +2809,7 @@
|
||||
"@trackConvertFormat": {
|
||||
"description": "Menu item - convert audio format"
|
||||
},
|
||||
"trackConvertFormatSubtitle": "Convert to MP3 or Opus",
|
||||
"trackConvertFormatSubtitle": "Konversi ke MP3, Opus, ALAC, atau FLAC",
|
||||
"@trackConvertFormatSubtitle": {
|
||||
"description": "Subtitle for convert format menu item"
|
||||
},
|
||||
@@ -2803,6 +2844,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"trackConvertConfirmMessageLossless": "Konversi dari {sourceFormat} ke {targetFormat}? (Lossless — tanpa kehilangan kualitas)\n\nFile asli akan dihapus setelah konversi.",
|
||||
"@trackConvertConfirmMessageLossless": {
|
||||
"description": "Confirmation dialog message for lossless-to-lossless conversion",
|
||||
"placeholders": {
|
||||
"sourceFormat": {
|
||||
"type": "String"
|
||||
},
|
||||
"targetFormat": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"trackConvertLosslessHint": "Konversi lossless — tanpa kehilangan kualitas",
|
||||
"@trackConvertLosslessHint": {
|
||||
"description": "Hint shown when converting between lossless formats"
|
||||
},
|
||||
"trackConvertConverting": "Converting audio...",
|
||||
"@trackConvertConverting": {
|
||||
"description": "Snackbar while converting"
|
||||
@@ -3114,4 +3171,4 @@
|
||||
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
|
||||
"description": "Subtitle when Track Artist is used for folder naming"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+71
-2
@@ -4,6 +4,7 @@ import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:spotiflac_android/app.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
@@ -13,6 +14,7 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/services/notification_service.dart';
|
||||
import 'package:spotiflac_android/services/share_intent_service.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/utils/local_library_scan_prefs.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
@@ -90,16 +92,19 @@ class _EagerInitialization extends ConsumerStatefulWidget {
|
||||
_EagerInitializationState();
|
||||
}
|
||||
|
||||
class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
|
||||
class _EagerInitializationState extends ConsumerState<_EagerInitialization>
|
||||
with WidgetsBindingObserver {
|
||||
ProviderSubscription<bool>? _localLibraryEnabledSub;
|
||||
Timer? _downloadHistoryWarmupTimer;
|
||||
Timer? _libraryCollectionsWarmupTimer;
|
||||
Timer? _localLibraryWarmupTimer;
|
||||
bool _localLibraryWarmupScheduled = false;
|
||||
bool _autoScanTriggeredOnLaunch = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
_initializeAppServices();
|
||||
@@ -110,6 +115,7 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_localLibraryEnabledSub?.close();
|
||||
_downloadHistoryWarmupTimer?.cancel();
|
||||
_libraryCollectionsWarmupTimer?.cancel();
|
||||
@@ -117,6 +123,13 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
_maybeAutoScanLocalLibrary();
|
||||
}
|
||||
}
|
||||
|
||||
void _initializeDeferredProviders() {
|
||||
_downloadHistoryWarmupTimer = _scheduleProviderWarmup(
|
||||
const Duration(milliseconds: 400),
|
||||
@@ -155,7 +168,63 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
|
||||
_localLibraryWarmupScheduled = true;
|
||||
_localLibraryWarmupTimer = _scheduleProviderWarmup(
|
||||
const Duration(milliseconds: 1600),
|
||||
() => ref.read(localLibraryProvider),
|
||||
() {
|
||||
ref.read(localLibraryProvider);
|
||||
// Trigger auto-scan after initial warmup on first app launch.
|
||||
if (!_autoScanTriggeredOnLaunch) {
|
||||
_autoScanTriggeredOnLaunch = true;
|
||||
// Give the provider a moment to load existing data before scanning.
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
if (mounted) _maybeAutoScanLocalLibrary();
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Checks whether an automatic incremental scan should be triggered based on
|
||||
/// the user's auto-scan preference and the time since the last scan.
|
||||
Future<void> _maybeAutoScanLocalLibrary() async {
|
||||
if (!mounted) return;
|
||||
|
||||
final settings = ref.read(settingsProvider);
|
||||
if (!settings.localLibraryEnabled) return;
|
||||
if (settings.localLibraryPath.isEmpty) return;
|
||||
if (settings.localLibraryAutoScan == 'off') return;
|
||||
|
||||
// Don't start a scan if one is already running.
|
||||
final libraryState = ref.read(localLibraryProvider);
|
||||
if (libraryState.isScanning) return;
|
||||
|
||||
// Determine cooldown based on auto-scan mode.
|
||||
final now = DateTime.now();
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final lastScanned = readLocalLibraryLastScannedAt(prefs);
|
||||
|
||||
if (lastScanned != null) {
|
||||
final elapsed = now.difference(lastScanned);
|
||||
|
||||
switch (settings.localLibraryAutoScan) {
|
||||
case 'on_open':
|
||||
// Cooldown of 10 minutes to prevent rapid re-scans.
|
||||
if (elapsed.inMinutes < 10) return;
|
||||
break;
|
||||
case 'daily':
|
||||
if (elapsed.inHours < 24) return;
|
||||
break;
|
||||
case 'weekly':
|
||||
if (elapsed.inDays < 7) return;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// All checks passed -- start an incremental scan.
|
||||
final iosBookmark = settings.localLibraryBookmark;
|
||||
ref.read(localLibraryProvider.notifier).startScan(
|
||||
settings.localLibraryPath,
|
||||
iosBookmark: iosBookmark.isNotEmpty ? iosBookmark : null,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -38,10 +38,8 @@ class AppSettings {
|
||||
final bool showExtensionStore;
|
||||
final String locale;
|
||||
final String lyricsMode;
|
||||
final String
|
||||
tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'opus_256', or 'opus_128'
|
||||
final int
|
||||
youtubeOpusBitrate; // YouTube Opus bitrate (supported: 128/256 kbps)
|
||||
youtubeOpusBitrate; // YouTube Opus bitrate (supported: 128/256/320 kbps)
|
||||
final int
|
||||
youtubeMp3Bitrate; // YouTube MP3 bitrate (supported: 128/256/320 kbps)
|
||||
final bool
|
||||
@@ -61,6 +59,8 @@ class AppSettings {
|
||||
localLibraryBookmark; // Base64-encoded iOS security-scoped bookmark
|
||||
final bool
|
||||
localLibraryShowDuplicates; // Show indicator when searching for existing tracks
|
||||
final String
|
||||
localLibraryAutoScan; // Auto-scan mode: 'off', 'on_open', 'daily', 'weekly'
|
||||
|
||||
final bool
|
||||
hasCompletedTutorial; // Track if user has completed the app tutorial
|
||||
@@ -114,7 +114,6 @@ class AppSettings {
|
||||
this.showExtensionStore = true,
|
||||
this.locale = 'system',
|
||||
this.lyricsMode = 'embed',
|
||||
this.tidalHighFormat = 'mp3_320',
|
||||
this.youtubeOpusBitrate = 256,
|
||||
this.youtubeMp3Bitrate = 320,
|
||||
this.useAllFilesAccess = false,
|
||||
@@ -126,6 +125,7 @@ class AppSettings {
|
||||
this.localLibraryPath = '',
|
||||
this.localLibraryBookmark = '',
|
||||
this.localLibraryShowDuplicates = true,
|
||||
this.localLibraryAutoScan = 'off',
|
||||
this.hasCompletedTutorial = false,
|
||||
this.lyricsProviders = const [
|
||||
'lrclib',
|
||||
@@ -178,7 +178,6 @@ class AppSettings {
|
||||
bool? showExtensionStore,
|
||||
String? locale,
|
||||
String? lyricsMode,
|
||||
String? tidalHighFormat,
|
||||
int? youtubeOpusBitrate,
|
||||
int? youtubeMp3Bitrate,
|
||||
bool? useAllFilesAccess,
|
||||
@@ -190,6 +189,7 @@ class AppSettings {
|
||||
String? localLibraryPath,
|
||||
String? localLibraryBookmark,
|
||||
bool? localLibraryShowDuplicates,
|
||||
String? localLibraryAutoScan,
|
||||
bool? hasCompletedTutorial,
|
||||
List<String>? lyricsProviders,
|
||||
bool? lyricsIncludeTranslationNetease,
|
||||
@@ -241,7 +241,6 @@ class AppSettings {
|
||||
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
|
||||
locale: locale ?? this.locale,
|
||||
lyricsMode: lyricsMode ?? this.lyricsMode,
|
||||
tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat,
|
||||
youtubeOpusBitrate: youtubeOpusBitrate ?? this.youtubeOpusBitrate,
|
||||
youtubeMp3Bitrate: youtubeMp3Bitrate ?? this.youtubeMp3Bitrate,
|
||||
useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess,
|
||||
@@ -256,6 +255,8 @@ class AppSettings {
|
||||
localLibraryBookmark: localLibraryBookmark ?? this.localLibraryBookmark,
|
||||
localLibraryShowDuplicates:
|
||||
localLibraryShowDuplicates ?? this.localLibraryShowDuplicates,
|
||||
localLibraryAutoScan:
|
||||
localLibraryAutoScan ?? this.localLibraryAutoScan,
|
||||
hasCompletedTutorial: hasCompletedTutorial ?? this.hasCompletedTutorial,
|
||||
lyricsProviders: lyricsProviders ?? this.lyricsProviders,
|
||||
lyricsIncludeTranslationNetease:
|
||||
|
||||
@@ -44,7 +44,6 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
showExtensionStore: json['showExtensionStore'] as bool? ?? true,
|
||||
locale: json['locale'] as String? ?? 'system',
|
||||
lyricsMode: json['lyricsMode'] as String? ?? 'embed',
|
||||
tidalHighFormat: json['tidalHighFormat'] as String? ?? 'mp3_320',
|
||||
youtubeOpusBitrate: (json['youtubeOpusBitrate'] as num?)?.toInt() ?? 256,
|
||||
youtubeMp3Bitrate: (json['youtubeMp3Bitrate'] as num?)?.toInt() ?? 320,
|
||||
useAllFilesAccess: json['useAllFilesAccess'] as bool? ?? false,
|
||||
@@ -58,6 +57,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
localLibraryBookmark: json['localLibraryBookmark'] as String? ?? '',
|
||||
localLibraryShowDuplicates:
|
||||
json['localLibraryShowDuplicates'] as bool? ?? true,
|
||||
localLibraryAutoScan: json['localLibraryAutoScan'] as String? ?? 'off',
|
||||
hasCompletedTutorial: json['hasCompletedTutorial'] as bool? ?? false,
|
||||
lyricsProviders:
|
||||
(json['lyricsProviders'] as List<dynamic>?)
|
||||
@@ -119,7 +119,6 @@ Map<String, dynamic> _$AppSettingsToJson(
|
||||
'showExtensionStore': instance.showExtensionStore,
|
||||
'locale': instance.locale,
|
||||
'lyricsMode': instance.lyricsMode,
|
||||
'tidalHighFormat': instance.tidalHighFormat,
|
||||
'youtubeOpusBitrate': instance.youtubeOpusBitrate,
|
||||
'youtubeMp3Bitrate': instance.youtubeMp3Bitrate,
|
||||
'useAllFilesAccess': instance.useAllFilesAccess,
|
||||
@@ -131,6 +130,7 @@ Map<String, dynamic> _$AppSettingsToJson(
|
||||
'localLibraryPath': instance.localLibraryPath,
|
||||
'localLibraryBookmark': instance.localLibraryBookmark,
|
||||
'localLibraryShowDuplicates': instance.localLibraryShowDuplicates,
|
||||
'localLibraryAutoScan': instance.localLibraryAutoScan,
|
||||
'hasCompletedTutorial': instance.hasCompletedTutorial,
|
||||
'lyricsProviders': instance.lyricsProviders,
|
||||
'lyricsIncludeTranslationNetease': instance.lyricsIncludeTranslationNetease,
|
||||
|
||||
@@ -770,6 +770,37 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
|
||||
/// Remove history entries where the file no longer exists on disk
|
||||
/// Returns the number of orphaned entries removed
|
||||
/// Audio file extensions that the app commonly produces or converts between.
|
||||
static const _audioExtensions = [
|
||||
'.flac',
|
||||
'.m4a',
|
||||
'.mp3',
|
||||
'.opus',
|
||||
'.ogg',
|
||||
'.wav',
|
||||
'.aac',
|
||||
];
|
||||
|
||||
/// When the original file is missing, check whether a sibling with a
|
||||
/// different audio extension exists (e.g. the user converted .flac → .opus).
|
||||
/// Returns the path of the first match found, or `null` if none exist.
|
||||
Future<String?> _findConvertedSibling(String originalPath) async {
|
||||
// Strip the current extension to get the base path.
|
||||
final dotIndex = originalPath.lastIndexOf('.');
|
||||
if (dotIndex < 0) return null;
|
||||
final basePath = originalPath.substring(0, dotIndex);
|
||||
final originalExt = originalPath.substring(dotIndex).toLowerCase();
|
||||
|
||||
for (final ext in _audioExtensions) {
|
||||
if (ext == originalExt) continue;
|
||||
final candidatePath = '$basePath$ext';
|
||||
try {
|
||||
if (await fileExists(candidatePath)) return candidatePath;
|
||||
} catch (_) {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<int> cleanupOrphanedDownloads() async {
|
||||
_historyLog.i('Starting orphaned downloads cleanup...');
|
||||
|
||||
@@ -791,7 +822,21 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
if (filePath == null || filePath.isEmpty) return null;
|
||||
pathById[id] = filePath;
|
||||
try {
|
||||
return MapEntry(id, await fileExists(filePath));
|
||||
if (await fileExists(filePath)) return MapEntry(id, true);
|
||||
|
||||
// Original file missing -- check for a converted sibling.
|
||||
final sibling = await _findConvertedSibling(filePath);
|
||||
if (sibling != null) {
|
||||
_historyLog.i(
|
||||
'Found converted sibling for $id: $filePath → $sibling',
|
||||
);
|
||||
// Update the stored path so future checks succeed immediately.
|
||||
await _db.updateFilePath(id, sibling);
|
||||
pathById[id] = sibling;
|
||||
return MapEntry(id, true);
|
||||
}
|
||||
|
||||
return MapEntry(id, false);
|
||||
} catch (e) {
|
||||
_historyLog.w('Error checking file existence for $id: $e');
|
||||
return MapEntry(id, false);
|
||||
@@ -960,6 +1005,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
int _lastNotifPercent = -1;
|
||||
int _lastNotifQueueCount = -1;
|
||||
final Set<String> _locallyCancelledItemIds = {};
|
||||
final Set<String> _pausePendingItemIds = {};
|
||||
|
||||
double _normalizeProgressForUi(double value) {
|
||||
final clamped = value.clamp(0.0, 1.0).toDouble();
|
||||
@@ -1279,6 +1325,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
if (localItem == null) {
|
||||
continue;
|
||||
}
|
||||
if (_isPausePending(itemId)) {
|
||||
PlatformBridge.clearItemProgress(itemId).catchError((_) {});
|
||||
continue;
|
||||
}
|
||||
if (localItem.status == DownloadStatus.skipped) {
|
||||
PlatformBridge.clearItemProgress(itemId).catchError((_) {});
|
||||
continue;
|
||||
@@ -1840,7 +1890,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
return '.opus';
|
||||
}
|
||||
if (service.toLowerCase() == 'tidal' && quality == 'HIGH') {
|
||||
return '.m4a';
|
||||
return '.flac'; // HIGH quality no longer available; fallback to FLAC
|
||||
}
|
||||
return '.flac';
|
||||
}
|
||||
@@ -2078,12 +2128,42 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
return resolved?.status == DownloadStatus.skipped;
|
||||
}
|
||||
|
||||
bool _isPausePending(String id) => _pausePendingItemIds.contains(id);
|
||||
|
||||
void _requeueItemForPause(String id) {
|
||||
final updatedItems = state.items
|
||||
.map((item) {
|
||||
if (item.id != id) return item;
|
||||
if (item.status == DownloadStatus.completed ||
|
||||
item.status == DownloadStatus.failed ||
|
||||
item.status == DownloadStatus.skipped) {
|
||||
return item;
|
||||
}
|
||||
return item.copyWith(
|
||||
status: DownloadStatus.queued,
|
||||
progress: 0,
|
||||
speedMBps: 0,
|
||||
bytesReceived: 0,
|
||||
);
|
||||
})
|
||||
.toList(growable: false);
|
||||
|
||||
final currentDownload = state.currentDownload?.id == id
|
||||
? null
|
||||
: state.currentDownload;
|
||||
state = state.copyWith(
|
||||
items: updatedItems,
|
||||
currentDownload: currentDownload,
|
||||
);
|
||||
}
|
||||
|
||||
void _requestNativeCancel(String id) {
|
||||
PlatformBridge.cancelDownload(id).catchError((_) {});
|
||||
PlatformBridge.clearItemProgress(id).catchError((_) {});
|
||||
}
|
||||
|
||||
void cancelItem(String id) {
|
||||
_pausePendingItemIds.remove(id);
|
||||
_locallyCancelledItemIds.add(id);
|
||||
updateItemStatus(id, DownloadStatus.skipped);
|
||||
_requestNativeCancel(id);
|
||||
@@ -2116,6 +2196,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
.toList(growable: false);
|
||||
|
||||
if (activeIds.isNotEmpty) {
|
||||
_pausePendingItemIds.addAll(activeIds);
|
||||
_locallyCancelledItemIds.addAll(activeIds);
|
||||
for (final id in activeIds) {
|
||||
_requestNativeCancel(id);
|
||||
@@ -2128,11 +2209,29 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
if (!wasProcessing) {
|
||||
_locallyCancelledItemIds.clear();
|
||||
}
|
||||
_pausePendingItemIds.clear();
|
||||
}
|
||||
|
||||
void pauseQueue() {
|
||||
if (state.isProcessing && !state.isPaused) {
|
||||
state = state.copyWith(isPaused: true);
|
||||
final activeIds = state.items
|
||||
.where(
|
||||
(item) =>
|
||||
item.status == DownloadStatus.downloading ||
|
||||
item.status == DownloadStatus.finalizing,
|
||||
)
|
||||
.map((item) => item.id)
|
||||
.toSet();
|
||||
|
||||
if (activeIds.isNotEmpty) {
|
||||
_pausePendingItemIds.addAll(activeIds);
|
||||
for (final id in activeIds) {
|
||||
_requestNativeCancel(id);
|
||||
_requeueItemForPause(id);
|
||||
}
|
||||
}
|
||||
|
||||
state = state.copyWith(isPaused: true, currentDownload: null);
|
||||
_notificationService.cancelDownloadNotification();
|
||||
_log.i('Queue paused');
|
||||
}
|
||||
@@ -2334,7 +2433,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Deezer CDN cover size pattern: /WxH-0-0-0-0.jpg
|
||||
static final _deezerSizeRegex = RegExp(r'/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$');
|
||||
|
||||
String _upgradeToMaxQualityCover(String coverUrl) {
|
||||
// Spotify CDN upgrade (hash-based size identifiers)
|
||||
const spotifySize300 = 'ab67616d00001e02';
|
||||
const spotifySize640 = 'ab67616d0000b273';
|
||||
const spotifySizeMax = 'ab67616d000082c1';
|
||||
@@ -2343,11 +2446,29 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
if (result.contains(spotifySize300)) {
|
||||
result = result.replaceFirst(spotifySize300, spotifySize640);
|
||||
}
|
||||
|
||||
if (result.contains(spotifySize640)) {
|
||||
result = result.replaceFirst(spotifySize640, spotifySizeMax);
|
||||
}
|
||||
|
||||
// Deezer CDN upgrade (1000x1000 → 1800x1800)
|
||||
if (result.contains('cdn-images.dzcdn.net')) {
|
||||
final upgraded = result.replaceFirst(
|
||||
_deezerSizeRegex,
|
||||
'/1800x1800-000000-80-0-0.jpg',
|
||||
);
|
||||
if (upgraded != result) {
|
||||
_log.d('Cover URL upgraded (Deezer): 1800x1800');
|
||||
result = upgraded;
|
||||
}
|
||||
}
|
||||
|
||||
// Tidal CDN upgrade (1280x1280 → origin)
|
||||
if (result.contains('resources.tidal.com') &&
|
||||
result.contains('/1280x1280.jpg')) {
|
||||
result = result.replaceFirst('/1280x1280.jpg', '/origin.jpg');
|
||||
_log.d('Cover URL upgraded (Tidal): origin');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -2383,7 +2504,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
backendResult['album_artist'] as String?,
|
||||
);
|
||||
|
||||
final hasOverrides = backendTrackNum != null ||
|
||||
final hasOverrides =
|
||||
backendTrackNum != null ||
|
||||
backendDiscNum != null ||
|
||||
backendYear != null ||
|
||||
backendAlbum != null ||
|
||||
@@ -3126,6 +3248,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
_log.d('Concurrent downloads: ${state.concurrentDownloads}');
|
||||
await _processQueueParallel();
|
||||
final stoppedWhilePaused = state.isPaused;
|
||||
|
||||
_stopProgressPolling();
|
||||
|
||||
@@ -3151,7 +3274,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
_log.i(
|
||||
'Queue stats - completed: $_completedInSession, failed: $_failedInSession, totalAtStart: $_totalQueuedAtStart',
|
||||
);
|
||||
if (_totalQueuedAtStart > 0) {
|
||||
if (!stoppedWhilePaused && _totalQueuedAtStart > 0) {
|
||||
await _notificationService.showQueueComplete(
|
||||
completedCount: _completedInSession,
|
||||
failedCount: _failedInSession,
|
||||
@@ -3166,13 +3289,17 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
}
|
||||
|
||||
_log.i('Queue processing finished');
|
||||
if (stoppedWhilePaused) {
|
||||
_log.i('Queue processing paused');
|
||||
} else {
|
||||
_log.i('Queue processing finished');
|
||||
}
|
||||
state = state.copyWith(isProcessing: false, currentDownload: null);
|
||||
|
||||
final hasQueuedItems = state.items.any(
|
||||
(item) => item.status == DownloadStatus.queued,
|
||||
);
|
||||
if (hasQueuedItems) {
|
||||
if (hasQueuedItems && !state.isPaused) {
|
||||
_log.i(
|
||||
'Found queued items after processing finished, restarting queue...',
|
||||
);
|
||||
@@ -3188,8 +3315,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
|
||||
while (true) {
|
||||
if (state.isPaused) {
|
||||
if (activeDownloads.isEmpty) {
|
||||
_log.d('Queue is paused and no active downloads remain');
|
||||
break;
|
||||
}
|
||||
_log.d('Queue is paused, waiting for active downloads...');
|
||||
await Future.delayed(_queueSchedulingInterval);
|
||||
await Future.any([
|
||||
Future.wait(activeDownloads.values),
|
||||
Future.delayed(_queueSchedulingInterval),
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -3200,7 +3334,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
|
||||
final queuedItems = state.items
|
||||
.where((item) => item.status == DownloadStatus.queued)
|
||||
.where(
|
||||
(item) =>
|
||||
item.status == DownloadStatus.queued &&
|
||||
!_pausePendingItemIds.contains(item.id),
|
||||
)
|
||||
.toList();
|
||||
|
||||
if (queuedItems.isEmpty && activeDownloads.isEmpty) {
|
||||
@@ -3245,11 +3383,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
_stopProgressPolling();
|
||||
final remainingIds = state.items.map((item) => item.id).toSet();
|
||||
_locallyCancelledItemIds.removeWhere((id) => !remainingIds.contains(id));
|
||||
_pausePendingItemIds.removeWhere((id) => !remainingIds.contains(id));
|
||||
}
|
||||
|
||||
Future<void> _downloadSingleItem(DownloadItem item) async {
|
||||
_log.d('Processing: ${item.track.name} by ${item.track.artistName}');
|
||||
_log.d('Cover URL: ${item.track.coverUrl}');
|
||||
var pausedDuringThisRun = false;
|
||||
|
||||
final currentItem = _findItemById(item.id) ?? item;
|
||||
if (_isLocallyCancelled(item.id, item: currentItem)) {
|
||||
@@ -3257,11 +3397,33 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isPausePending(item.id)) {
|
||||
pausedDuringThisRun = true;
|
||||
_requeueItemForPause(item.id);
|
||||
_log.i('Download is pause-pending before start, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
state = state.copyWith(currentDownload: item);
|
||||
|
||||
updateItemStatus(item.id, DownloadStatus.downloading);
|
||||
|
||||
try {
|
||||
bool shouldAbortWork(String stage) {
|
||||
final current = _findItemById(item.id);
|
||||
if (_isLocallyCancelled(item.id, item: current)) {
|
||||
_log.i('Download was cancelled $stage, skipping');
|
||||
return true;
|
||||
}
|
||||
if (_isPausePending(item.id)) {
|
||||
pausedDuringThisRun = true;
|
||||
_requeueItemForPause(item.id);
|
||||
_log.i('Download pause requested $stage, re-queueing');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
final settings = ref.read(settingsProvider);
|
||||
final metadataEmbeddingEnabled = settings.embedMetadata;
|
||||
|
||||
@@ -3342,6 +3504,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
_log.w('Failed to enrich metadata: $e');
|
||||
_log.w('Stack trace: $stack');
|
||||
}
|
||||
|
||||
if (shouldAbortWork('during metadata enrichment')) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_log.d('Track coverUrl after enrichment: ${trackToDownload.coverUrl}');
|
||||
@@ -3428,6 +3594,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
String? genre;
|
||||
String? label;
|
||||
String? copyright;
|
||||
final extensionState = ref.read(extensionProvider);
|
||||
final selectedExtensionDownloadProvider =
|
||||
settings.useExtensionProviders &&
|
||||
extensionState.extensions.any(
|
||||
(e) =>
|
||||
e.enabled &&
|
||||
e.hasDownloadProvider &&
|
||||
e.id.toLowerCase() == item.service.toLowerCase(),
|
||||
);
|
||||
|
||||
String? deezerTrackId = trackToDownload.deezerId;
|
||||
if (deezerTrackId == null && trackToDownload.id.startsWith('deezer:')) {
|
||||
@@ -3455,10 +3630,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
} catch (e) {
|
||||
_log.w('Failed to search Deezer by ISRC: $e');
|
||||
}
|
||||
|
||||
if (shouldAbortWork('during Deezer ISRC lookup')) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Use SongLink to convert Spotify ID to Deezer ID
|
||||
if (deezerTrackId == null &&
|
||||
if (!selectedExtensionDownloadProvider &&
|
||||
deezerTrackId == null &&
|
||||
trackToDownload.id.isNotEmpty &&
|
||||
!trackToDownload.id.startsWith('deezer:') &&
|
||||
!trackToDownload.id.startsWith('extension:')) {
|
||||
@@ -3555,6 +3735,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
} catch (e) {
|
||||
_log.w('Failed to convert Spotify to Deezer via SongLink: $e');
|
||||
}
|
||||
|
||||
if (shouldAbortWork('during SongLink availability lookup')) {
|
||||
return;
|
||||
}
|
||||
} else if (selectedExtensionDownloadProvider && deezerTrackId == null) {
|
||||
_log.d(
|
||||
'Skipping Flutter SongLink Deezer prelookup for extension provider: ${item.service}',
|
||||
);
|
||||
}
|
||||
|
||||
if (deezerTrackId != null && deezerTrackId.isNotEmpty) {
|
||||
@@ -3574,11 +3762,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
} catch (e) {
|
||||
_log.w('Failed to fetch extended metadata from Deezer: $e');
|
||||
}
|
||||
|
||||
if (shouldAbortWork('during extended metadata lookup')) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> result;
|
||||
|
||||
final extensionState = ref.read(extensionProvider);
|
||||
final hasActiveExtensions = extensionState.extensions.any(
|
||||
(e) => e.enabled,
|
||||
);
|
||||
@@ -3612,6 +3803,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}',
|
||||
);
|
||||
}
|
||||
|
||||
if (!useSaf) {
|
||||
await _ensureDirExists(outputDir, label: 'Output folder');
|
||||
}
|
||||
|
||||
_log.d('Output dir: $outputDir');
|
||||
|
||||
final normalizedTrackNumber =
|
||||
@@ -3687,8 +3883,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
);
|
||||
}
|
||||
|
||||
if (_isLocallyCancelled(item.id)) {
|
||||
_log.i('Download was cancelled before native download start, skipping');
|
||||
if (shouldAbortWork('before native download start')) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3730,10 +3925,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
_log.d('Result: $result');
|
||||
|
||||
final itemAfterResult = _findItemById(item.id);
|
||||
final cancelledAfterResult =
|
||||
itemAfterResult == null ||
|
||||
_isLocallyCancelled(item.id, item: itemAfterResult);
|
||||
if (cancelledAfterResult) {
|
||||
if (itemAfterResult == null ||
|
||||
_isLocallyCancelled(item.id, item: itemAfterResult)) {
|
||||
_log.i('Download was cancelled, skipping result processing');
|
||||
final filePath = result['file_path'] as String?;
|
||||
if (filePath != null && result['success'] == true) {
|
||||
@@ -3743,6 +3936,18 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isPausePending(item.id)) {
|
||||
pausedDuringThisRun = true;
|
||||
final filePath = result['file_path'] as String?;
|
||||
if (filePath != null && result['success'] == true) {
|
||||
await deleteFile(filePath);
|
||||
_log.d('Deleted paused download file: $filePath');
|
||||
}
|
||||
_requeueItemForPause(item.id);
|
||||
_log.i('Download pause requested after result, re-queueing');
|
||||
return;
|
||||
}
|
||||
|
||||
if (result['success'] == true) {
|
||||
var filePath = result['file_path'] as String?;
|
||||
final reportedFileName = result['file_name'] as String?;
|
||||
@@ -3903,7 +4108,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
isContentUriPath &&
|
||||
effectiveSafMode &&
|
||||
actualService == 'tidal' &&
|
||||
quality != 'HIGH' &&
|
||||
filePath.endsWith('.flac') &&
|
||||
(mimeType == null || mimeType.contains('flac'));
|
||||
|
||||
@@ -3918,73 +4122,50 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final currentFilePath = filePath;
|
||||
|
||||
if (isContentUriPath && effectiveSafMode) {
|
||||
if (quality == 'HIGH') {
|
||||
final tidalHighFormat = settings.tidalHighFormat;
|
||||
_log.i(
|
||||
'Tidal HIGH quality (SAF), converting M4A to $tidalHighFormat...',
|
||||
);
|
||||
|
||||
final tempPath = await _copySafToTemp(currentFilePath);
|
||||
if (tempPath != null) {
|
||||
String? convertedPath;
|
||||
try {
|
||||
_log.d('M4A file detected (SAF), converting to FLAC...');
|
||||
final tempPath = await _copySafToTemp(currentFilePath);
|
||||
if (tempPath != null) {
|
||||
String? flacPath;
|
||||
try {
|
||||
final length = await File(tempPath).length();
|
||||
if (length < 1024) {
|
||||
_log.w('Temp M4A is too small (<1KB), skipping conversion');
|
||||
} else {
|
||||
updateItemStatus(
|
||||
item.id,
|
||||
DownloadStatus.downloading,
|
||||
progress: 0.95,
|
||||
);
|
||||
|
||||
final format = tidalHighFormat.startsWith('opus')
|
||||
? 'opus'
|
||||
: 'mp3';
|
||||
convertedPath = await FFmpegService.convertM4aToLossy(
|
||||
tempPath,
|
||||
format: format,
|
||||
bitrate: tidalHighFormat,
|
||||
deleteOriginal: false,
|
||||
);
|
||||
|
||||
if (convertedPath != null) {
|
||||
_log.i(
|
||||
'Successfully converted M4A to $format (temp): $convertedPath',
|
||||
);
|
||||
_log.i('Embedding metadata to $format...');
|
||||
updateItemStatus(
|
||||
item.id,
|
||||
DownloadStatus.downloading,
|
||||
progress: 0.99,
|
||||
flacPath = await FFmpegService.convertM4aToFlac(tempPath);
|
||||
if (flacPath != null) {
|
||||
_log.d('Converted to FLAC (temp): $flacPath');
|
||||
_log.d('Embedding metadata and cover to converted FLAC...');
|
||||
final finalTrack = _buildTrackForMetadataEmbedding(
|
||||
trackToDownload,
|
||||
result,
|
||||
resolvedAlbumArtist,
|
||||
);
|
||||
|
||||
final backendGenre = result['genre'] as String?;
|
||||
final backendLabel = result['label'] as String?;
|
||||
final backendCopyright = result['copyright'] as String?;
|
||||
|
||||
if (format == 'mp3') {
|
||||
await _embedMetadataToMp3(
|
||||
convertedPath,
|
||||
trackToDownload,
|
||||
genre: backendGenre ?? genre,
|
||||
label: backendLabel ?? label,
|
||||
copyright: backendCopyright,
|
||||
);
|
||||
} else {
|
||||
await _embedMetadataToOpus(
|
||||
convertedPath,
|
||||
trackToDownload,
|
||||
genre: backendGenre ?? genre,
|
||||
label: backendLabel ?? label,
|
||||
copyright: backendCopyright,
|
||||
);
|
||||
}
|
||||
await _embedMetadataAndCover(
|
||||
flacPath,
|
||||
finalTrack,
|
||||
genre: backendGenre ?? genre,
|
||||
label: backendLabel ?? label,
|
||||
copyright: backendCopyright,
|
||||
writeExternalLrc: false,
|
||||
);
|
||||
|
||||
final newExt = format == 'opus' ? '.opus' : '.mp3';
|
||||
final newFileName = '${safBaseName ?? 'track'}$newExt';
|
||||
final newFileName = '${safBaseName ?? 'track'}.flac';
|
||||
final newUri = await _writeTempToSaf(
|
||||
treeUri: settings.downloadTreeUri,
|
||||
relativeDir: effectiveOutputDir,
|
||||
fileName: newFileName,
|
||||
mimeType: _mimeTypeForExt(newExt),
|
||||
srcPath: convertedPath,
|
||||
mimeType: _mimeTypeForExt('.flac'),
|
||||
srcPath: flacPath,
|
||||
);
|
||||
|
||||
if (newUri != null) {
|
||||
@@ -3993,58 +4174,60 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
filePath = newUri;
|
||||
finalSafFileName = newFileName;
|
||||
final bitrateDisplay = tidalHighFormat.contains('_')
|
||||
? '${tidalHighFormat.split('_').last}kbps'
|
||||
: '320kbps';
|
||||
actualQuality = '${format.toUpperCase()} $bitrateDisplay';
|
||||
} else {
|
||||
_log.w(
|
||||
'Failed to write converted $format to SAF, keeping M4A',
|
||||
);
|
||||
actualQuality = 'AAC 320kbps';
|
||||
_log.w('Failed to write FLAC to SAF, keeping M4A');
|
||||
}
|
||||
} else {
|
||||
_log.w(
|
||||
'M4A to $format conversion failed, keeping M4A file',
|
||||
);
|
||||
actualQuality = 'AAC 320kbps';
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('SAF M4A conversion failed: $e');
|
||||
actualQuality = 'AAC 320kbps';
|
||||
} finally {
|
||||
// Clean up temp files
|
||||
try {
|
||||
await File(tempPath).delete();
|
||||
} catch (_) {}
|
||||
if (convertedPath != null) {
|
||||
try {
|
||||
await File(convertedPath).delete();
|
||||
} catch (_) {}
|
||||
_log.w('FFmpeg conversion returned null, keeping M4A file');
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_log.d('M4A file detected (SAF), converting to FLAC...');
|
||||
final tempPath = await _copySafToTemp(currentFilePath);
|
||||
if (tempPath != null) {
|
||||
String? flacPath;
|
||||
} catch (e) {
|
||||
_log.w('SAF M4A->FLAC conversion failed: $e');
|
||||
} finally {
|
||||
// Clean up temp files
|
||||
try {
|
||||
final length = await File(tempPath).length();
|
||||
if (length < 1024) {
|
||||
_log.w('Temp M4A is too small (<1KB), skipping conversion');
|
||||
} else {
|
||||
updateItemStatus(
|
||||
item.id,
|
||||
DownloadStatus.downloading,
|
||||
progress: 0.95,
|
||||
);
|
||||
flacPath = await FFmpegService.convertM4aToFlac(tempPath);
|
||||
if (flacPath != null) {
|
||||
_log.d('Converted to FLAC (temp): $flacPath');
|
||||
_log.d(
|
||||
'Embedding metadata and cover to converted FLAC...',
|
||||
);
|
||||
await File(tempPath).delete();
|
||||
} catch (_) {}
|
||||
if (flacPath != null) {
|
||||
try {
|
||||
await File(flacPath).delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_log.d(
|
||||
'M4A file detected (Hi-Res DASH stream), attempting conversion to FLAC...',
|
||||
);
|
||||
|
||||
try {
|
||||
final file = File(currentFilePath);
|
||||
if (!await file.exists()) {
|
||||
_log.e('File does not exist at path: $filePath');
|
||||
} else {
|
||||
final length = await file.length();
|
||||
_log.i('File size before conversion: ${length / 1024} KB');
|
||||
|
||||
if (length < 1024) {
|
||||
_log.w(
|
||||
'File is too small (<1KB), skipping conversion. Download might be corrupt.',
|
||||
);
|
||||
} else {
|
||||
updateItemStatus(
|
||||
item.id,
|
||||
DownloadStatus.downloading,
|
||||
progress: 0.95,
|
||||
);
|
||||
final flacPath = await FFmpegService.convertM4aToFlac(
|
||||
currentFilePath,
|
||||
);
|
||||
|
||||
if (flacPath != null) {
|
||||
filePath = flacPath;
|
||||
_log.d('Converted to FLAC: $flacPath');
|
||||
|
||||
_log.d('Embedding metadata and cover to converted FLAC...');
|
||||
try {
|
||||
final finalTrack = _buildTrackForMetadataEmbedding(
|
||||
trackToDownload,
|
||||
result,
|
||||
@@ -4055,201 +4238,32 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final backendLabel = result['label'] as String?;
|
||||
final backendCopyright = result['copyright'] as String?;
|
||||
|
||||
if (backendGenre != null ||
|
||||
backendLabel != null ||
|
||||
backendCopyright != null) {
|
||||
_log.d(
|
||||
'Extended metadata from backend - Genre: $backendGenre, Label: $backendLabel, Copyright: $backendCopyright',
|
||||
);
|
||||
}
|
||||
|
||||
await _embedMetadataAndCover(
|
||||
flacPath,
|
||||
finalTrack,
|
||||
genre: backendGenre ?? genre,
|
||||
label: backendLabel ?? label,
|
||||
copyright: backendCopyright,
|
||||
writeExternalLrc: false,
|
||||
);
|
||||
|
||||
final newFileName = '${safBaseName ?? 'track'}.flac';
|
||||
final newUri = await _writeTempToSaf(
|
||||
treeUri: settings.downloadTreeUri,
|
||||
relativeDir: effectiveOutputDir,
|
||||
fileName: newFileName,
|
||||
mimeType: _mimeTypeForExt('.flac'),
|
||||
srcPath: flacPath,
|
||||
);
|
||||
|
||||
if (newUri != null) {
|
||||
if (newUri != currentFilePath) {
|
||||
await _deleteSafFile(currentFilePath);
|
||||
}
|
||||
filePath = newUri;
|
||||
finalSafFileName = newFileName;
|
||||
} else {
|
||||
_log.w('Failed to write FLAC to SAF, keeping M4A');
|
||||
}
|
||||
} else {
|
||||
_log.w(
|
||||
'FFmpeg conversion returned null, keeping M4A file',
|
||||
);
|
||||
_log.d('Metadata and cover embedded successfully');
|
||||
} catch (e) {
|
||||
_log.w('Warning: Failed to embed metadata/cover: $e');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('SAF M4A->FLAC conversion failed: $e');
|
||||
} finally {
|
||||
// Clean up temp files
|
||||
try {
|
||||
await File(tempPath).delete();
|
||||
} catch (_) {}
|
||||
if (flacPath != null) {
|
||||
try {
|
||||
await File(flacPath).delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (quality == 'HIGH') {
|
||||
final tidalHighFormat = settings.tidalHighFormat;
|
||||
_log.i(
|
||||
'Tidal HIGH quality download, converting M4A to $tidalHighFormat...',
|
||||
);
|
||||
|
||||
try {
|
||||
updateItemStatus(
|
||||
item.id,
|
||||
DownloadStatus.downloading,
|
||||
progress: 0.95,
|
||||
);
|
||||
|
||||
final format = tidalHighFormat.startsWith('opus')
|
||||
? 'opus'
|
||||
: 'mp3';
|
||||
final convertedPath = await FFmpegService.convertM4aToLossy(
|
||||
currentFilePath,
|
||||
format: format,
|
||||
bitrate: tidalHighFormat,
|
||||
deleteOriginal: true,
|
||||
);
|
||||
|
||||
if (convertedPath != null) {
|
||||
filePath = convertedPath;
|
||||
final bitrateDisplay = tidalHighFormat.contains('_')
|
||||
? '${tidalHighFormat.split('_').last}kbps'
|
||||
: '320kbps';
|
||||
actualQuality = '${format.toUpperCase()} $bitrateDisplay';
|
||||
_log.i(
|
||||
'Successfully converted M4A to $format: $convertedPath',
|
||||
);
|
||||
|
||||
_log.i('Embedding metadata to $format...');
|
||||
updateItemStatus(
|
||||
item.id,
|
||||
DownloadStatus.downloading,
|
||||
progress: 0.99,
|
||||
);
|
||||
|
||||
final backendGenre = result['genre'] as String?;
|
||||
final backendLabel = result['label'] as String?;
|
||||
final backendCopyright = result['copyright'] as String?;
|
||||
|
||||
if (format == 'mp3') {
|
||||
await _embedMetadataToMp3(
|
||||
convertedPath,
|
||||
trackToDownload,
|
||||
genre: backendGenre ?? genre,
|
||||
label: backendLabel ?? label,
|
||||
copyright: backendCopyright,
|
||||
);
|
||||
} else {
|
||||
await _embedMetadataToOpus(
|
||||
convertedPath,
|
||||
trackToDownload,
|
||||
genre: backendGenre ?? genre,
|
||||
label: backendLabel ?? label,
|
||||
copyright: backendCopyright,
|
||||
);
|
||||
}
|
||||
_log.d('Metadata embedded successfully');
|
||||
} else {
|
||||
_log.w('M4A to $format conversion failed, keeping M4A file');
|
||||
actualQuality = 'AAC 320kbps';
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('M4A conversion process failed: $e, keeping M4A file');
|
||||
actualQuality = 'AAC 320kbps';
|
||||
}
|
||||
} else {
|
||||
_log.d(
|
||||
'M4A file detected (Hi-Res DASH stream), attempting conversion to FLAC...',
|
||||
);
|
||||
|
||||
try {
|
||||
final file = File(currentFilePath);
|
||||
if (!await file.exists()) {
|
||||
_log.e('File does not exist at path: $filePath');
|
||||
} else {
|
||||
final length = await file.length();
|
||||
_log.i('File size before conversion: ${length / 1024} KB');
|
||||
|
||||
if (length < 1024) {
|
||||
_log.w(
|
||||
'File is too small (<1KB), skipping conversion. Download might be corrupt.',
|
||||
);
|
||||
} else {
|
||||
updateItemStatus(
|
||||
item.id,
|
||||
DownloadStatus.downloading,
|
||||
progress: 0.95,
|
||||
);
|
||||
final flacPath = await FFmpegService.convertM4aToFlac(
|
||||
currentFilePath,
|
||||
);
|
||||
|
||||
if (flacPath != null) {
|
||||
filePath = flacPath;
|
||||
_log.d('Converted to FLAC: $flacPath');
|
||||
|
||||
_log.d(
|
||||
'Embedding metadata and cover to converted FLAC...',
|
||||
);
|
||||
try {
|
||||
final finalTrack = _buildTrackForMetadataEmbedding(
|
||||
trackToDownload,
|
||||
result,
|
||||
resolvedAlbumArtist,
|
||||
);
|
||||
|
||||
final backendGenre = result['genre'] as String?;
|
||||
final backendLabel = result['label'] as String?;
|
||||
final backendCopyright = result['copyright'] as String?;
|
||||
|
||||
if (backendGenre != null ||
|
||||
backendLabel != null ||
|
||||
backendCopyright != null) {
|
||||
_log.d(
|
||||
'Extended metadata from backend - Genre: $backendGenre, Label: $backendLabel, Copyright: $backendCopyright',
|
||||
);
|
||||
}
|
||||
|
||||
await _embedMetadataAndCover(
|
||||
flacPath,
|
||||
finalTrack,
|
||||
genre: backendGenre ?? genre,
|
||||
label: backendLabel ?? label,
|
||||
copyright: backendCopyright,
|
||||
);
|
||||
_log.d('Metadata and cover embedded successfully');
|
||||
} catch (e) {
|
||||
_log.w('Warning: Failed to embed metadata/cover: $e');
|
||||
}
|
||||
} else {
|
||||
_log.w(
|
||||
'FFmpeg conversion returned null, keeping M4A file',
|
||||
);
|
||||
}
|
||||
_log.w('FFmpeg conversion returned null, keeping M4A file');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w(
|
||||
'FFmpeg conversion process failed: $e, keeping M4A file',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('FFmpeg conversion process failed: $e, keeping M4A file');
|
||||
}
|
||||
}
|
||||
} else if (metadataEmbeddingEnabled &&
|
||||
@@ -4467,6 +4481,19 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isPausePending(item.id)) {
|
||||
pausedDuringThisRun = true;
|
||||
if (filePath != null) {
|
||||
await deleteFile(filePath);
|
||||
_log.d(
|
||||
'Deleted paused download file during finalization: $filePath',
|
||||
);
|
||||
}
|
||||
_requeueItemForPause(item.id);
|
||||
_log.i('Download pause requested during finalization, re-queueing');
|
||||
return;
|
||||
}
|
||||
|
||||
// SAF downloads should end with content URI. If we still have a
|
||||
// transient FD path, recover URI from SAF metadata to keep history
|
||||
// dedup/exclusion stable.
|
||||
@@ -4734,11 +4761,26 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isPausePending(item.id)) {
|
||||
pausedDuringThisRun = true;
|
||||
_requeueItemForPause(item.id);
|
||||
_log.i('Download pause requested after backend failure, re-queueing');
|
||||
return;
|
||||
}
|
||||
|
||||
final errorMsg = result['error'] as String? ?? 'Download failed';
|
||||
final errorTypeStr = result['error_type'] as String? ?? 'unknown';
|
||||
if (errorTypeStr == 'cancelled') {
|
||||
_log.i('Download was cancelled by backend, skipping error handling');
|
||||
updateItemStatus(item.id, DownloadStatus.skipped);
|
||||
if (_isPausePending(item.id)) {
|
||||
pausedDuringThisRun = true;
|
||||
_requeueItemForPause(item.id);
|
||||
_log.i('Download was paused by backend cancellation, re-queueing');
|
||||
} else {
|
||||
_log.i(
|
||||
'Download was cancelled by backend, skipping error handling',
|
||||
);
|
||||
updateItemStatus(item.id, DownloadStatus.skipped);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -4797,6 +4839,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isPausePending(item.id)) {
|
||||
pausedDuringThisRun = true;
|
||||
_requeueItemForPause(item.id);
|
||||
_log.i('Download pause requested after exception, re-queueing');
|
||||
return;
|
||||
}
|
||||
|
||||
_log.e('Exception: $e', e, stackTrace);
|
||||
|
||||
String errorMsg = e.toString();
|
||||
@@ -4822,6 +4871,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
} catch (cleanupErr) {
|
||||
_log.e('Post-exception connection cleanup failed: $cleanupErr');
|
||||
}
|
||||
} finally {
|
||||
if (pausedDuringThisRun) {
|
||||
_pausePendingItemIds.remove(item.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,11 +9,11 @@ import 'package:spotiflac_android/services/library_database.dart';
|
||||
import 'package:spotiflac_android/services/notification_service.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
import 'package:spotiflac_android/utils/local_library_scan_prefs.dart';
|
||||
import 'package:spotiflac_android/utils/path_match_keys.dart';
|
||||
|
||||
final _log = AppLogger('LocalLibrary');
|
||||
|
||||
const _lastScannedAtKey = 'local_library_last_scanned_at';
|
||||
const _excludedDownloadedCountKey = 'local_library_excluded_downloaded_count';
|
||||
final _prefs = SharedPreferences.getInstance();
|
||||
|
||||
@@ -165,10 +165,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
var excludedDownloadedCount = 0;
|
||||
try {
|
||||
final prefs = await prefsFuture;
|
||||
final lastScannedAtStr = prefs.getString(_lastScannedAtKey);
|
||||
if (lastScannedAtStr != null && lastScannedAtStr.isNotEmpty) {
|
||||
lastScannedAt = DateTime.tryParse(lastScannedAtStr);
|
||||
}
|
||||
lastScannedAt = readLocalLibraryLastScannedAt(prefs);
|
||||
excludedDownloadedCount =
|
||||
prefs.getInt(_excludedDownloadedCountKey) ?? 0;
|
||||
} catch (e) {
|
||||
@@ -332,11 +329,16 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
if (items.isNotEmpty) {
|
||||
await _db.upsertBatch(items.map((e) => e.toJson()).toList());
|
||||
}
|
||||
final persistedItems =
|
||||
(await _db.getAll())
|
||||
.map(LocalLibraryItem.fromJson)
|
||||
.toList(growable: false)
|
||||
..sort(_compareLibraryItems);
|
||||
|
||||
final now = DateTime.now();
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_lastScannedAtKey, now.toIso8601String());
|
||||
await writeLocalLibraryLastScannedAt(prefs, now);
|
||||
await prefs.setInt(_excludedDownloadedCountKey, skippedDownloads);
|
||||
_log.d('Saved lastScannedAt: $now');
|
||||
} catch (e) {
|
||||
@@ -344,7 +346,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
}
|
||||
|
||||
state = state.copyWith(
|
||||
items: items,
|
||||
items: persistedItems,
|
||||
isScanning: false,
|
||||
scanProgress: 100,
|
||||
lastScannedAt: now,
|
||||
@@ -353,11 +355,11 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
);
|
||||
|
||||
_log.i(
|
||||
'Full scan complete: ${items.length} tracks found, '
|
||||
'Full scan complete: ${persistedItems.length} tracks found, '
|
||||
'$skippedDownloads already in downloads',
|
||||
);
|
||||
await _showScanCompleteNotification(
|
||||
totalTracks: items.length,
|
||||
totalTracks: persistedItems.length,
|
||||
excludedDownloadedCount: skippedDownloads,
|
||||
errorCount: state.scanErrorCount,
|
||||
);
|
||||
@@ -442,8 +444,14 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
'$skippedCount skipped, ${deletedPaths.length} deleted, $totalFiles total',
|
||||
);
|
||||
|
||||
// Build the incremental merge base from SQLite, not the current
|
||||
// provider state. Startup auto-scan can fire before `state.items` has
|
||||
// finished loading, which would otherwise drop unchanged rows from the
|
||||
// in-memory library until a manual full rescan.
|
||||
final existingJson = await _db.getAll();
|
||||
final currentByPath = <String, LocalLibraryItem>{
|
||||
for (final item in state.items) item.filePath: item,
|
||||
for (final item in existingJson.map(LocalLibraryItem.fromJson))
|
||||
item.filePath: item,
|
||||
};
|
||||
final existingDownloadedPaths = <String>[];
|
||||
currentByPath.removeWhere((path, _) {
|
||||
@@ -494,13 +502,16 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
_log.i('Deleted $deleteCount items from database');
|
||||
}
|
||||
|
||||
final items = currentByPath.values.toList(growable: false)
|
||||
..sort(_compareLibraryItems);
|
||||
final items =
|
||||
(await _db.getAll())
|
||||
.map(LocalLibraryItem.fromJson)
|
||||
.toList(growable: false)
|
||||
..sort(_compareLibraryItems);
|
||||
|
||||
final now = DateTime.now();
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_lastScannedAtKey, now.toIso8601String());
|
||||
await writeLocalLibraryLastScannedAt(prefs, now);
|
||||
await prefs.setInt(_excludedDownloadedCountKey, skippedDownloads);
|
||||
_log.d('Saved lastScannedAt: $now');
|
||||
} catch (e) {
|
||||
@@ -818,7 +829,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_lastScannedAtKey);
|
||||
await clearLocalLibraryLastScannedAt(prefs);
|
||||
await prefs.remove(_excludedDownloadedCountKey);
|
||||
} catch (e) {
|
||||
_log.w('Failed to clear lastScannedAt: $e');
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:spotiflac_android/models/settings.dart';
|
||||
import 'package:spotiflac_android/constants/app_info.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
const _settingsKey = 'app_settings';
|
||||
const _migrationVersionKey = 'settings_migration_version';
|
||||
const _currentMigrationVersion = 5;
|
||||
const _currentMigrationVersion = 6;
|
||||
const _spotifyClientSecretKey = 'spotify_client_secret';
|
||||
final _log = AppLogger('SettingsProvider');
|
||||
|
||||
class SettingsNotifier extends Notifier<AppSettings> {
|
||||
static const List<int> _youtubeOpusSupportedBitrates = [128, 256];
|
||||
static const List<int> _youtubeOpusSupportedBitrates = [128, 256, 320];
|
||||
static const List<int> _youtubeMp3SupportedBitrates = [128, 256, 320];
|
||||
static final RegExp _isoRegionPattern = RegExp(r'^[A-Z]{2}$');
|
||||
|
||||
@@ -37,6 +39,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
state = AppSettings.fromJson(jsonDecode(json));
|
||||
|
||||
await _runMigrations(prefs);
|
||||
await _normalizeIosDownloadDirectoryIfNeeded();
|
||||
await _normalizeYouTubeBitratesIfNeeded();
|
||||
await _normalizeSongLinkRegionIfNeeded();
|
||||
}
|
||||
@@ -114,6 +117,10 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
useCustomSpotifyCredentials: false,
|
||||
);
|
||||
}
|
||||
// Migration 6: Tidal HIGH quality removed — migrate to LOSSLESS
|
||||
if (state.audioQuality == 'HIGH') {
|
||||
state = state.copyWith(audioQuality: 'LOSSLESS');
|
||||
}
|
||||
state = state.copyWith(lastSeenVersion: AppInfo.version);
|
||||
await prefs.setInt(_migrationVersionKey, _currentMigrationVersion);
|
||||
await _saveSettings();
|
||||
@@ -189,6 +196,20 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
await _saveSettings();
|
||||
}
|
||||
|
||||
Future<void> _normalizeIosDownloadDirectoryIfNeeded() async {
|
||||
if (!Platform.isIOS) return;
|
||||
|
||||
final currentDir = state.downloadDirectory.trim();
|
||||
if (currentDir.isEmpty) return;
|
||||
|
||||
final normalizedDir = await validateOrFixIosPath(currentDir);
|
||||
if (normalizedDir == currentDir) return;
|
||||
|
||||
_log.i('Normalized iOS download directory: $currentDir -> $normalizedDir');
|
||||
state = state.copyWith(downloadDirectory: normalizedDir);
|
||||
await _saveSettings();
|
||||
}
|
||||
|
||||
String _normalizeSongLinkRegion(String region) {
|
||||
final normalized = region.trim().toUpperCase();
|
||||
if (_isoRegionPattern.hasMatch(normalized)) return normalized;
|
||||
@@ -385,8 +406,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
}
|
||||
|
||||
void setMetadataSource(String source) {
|
||||
final normalized = source == 'deezer' ? 'deezer' : 'deezer';
|
||||
state = state.copyWith(metadataSource: normalized);
|
||||
state = state.copyWith(metadataSource: source);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
@@ -430,11 +450,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setTidalHighFormat(String format) {
|
||||
state = state.copyWith(tidalHighFormat: format);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setYoutubeOpusBitrate(int bitrate) {
|
||||
final normalized = _normalizeYouTubeOpusBitrate(bitrate);
|
||||
state = state.copyWith(youtubeOpusBitrate: normalized);
|
||||
@@ -502,6 +517,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setLocalLibraryAutoScan(String mode) {
|
||||
state = state.copyWith(localLibraryAutoScan: mode);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setTutorialComplete() {
|
||||
state = state.copyWith(hasCompletedTutorial: true);
|
||||
_saveSettings();
|
||||
|
||||
@@ -958,9 +958,16 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
final durationMs = _extractDurationMs(data);
|
||||
|
||||
final itemType = data['item_type']?.toString();
|
||||
final effectiveSource =
|
||||
source ?? data['source']?.toString() ?? data['provider_id']?.toString();
|
||||
final spotifyId = (data['spotify_id'] ?? '').toString();
|
||||
final nativeId = (data['id'] ?? '').toString();
|
||||
final preferredId = effectiveSource != null && effectiveSource.isNotEmpty
|
||||
? (nativeId.isNotEmpty ? nativeId : spotifyId)
|
||||
: (spotifyId.isNotEmpty ? spotifyId : nativeId);
|
||||
|
||||
return Track(
|
||||
id: (data['spotify_id'] ?? data['id'] ?? '').toString(),
|
||||
id: preferredId,
|
||||
name: (data['name'] ?? '').toString(),
|
||||
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
|
||||
albumName: (data['album_name'] ?? data['album'] ?? '').toString(),
|
||||
@@ -974,10 +981,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
discNumber: data['disc_number'] as int?,
|
||||
releaseDate: data['release_date']?.toString(),
|
||||
totalTracks: data['total_tracks'] as int?,
|
||||
source:
|
||||
source ??
|
||||
data['source']?.toString() ??
|
||||
data['provider_id']?.toString(),
|
||||
source: effectiveSource,
|
||||
albumType: data['album_type']?.toString(),
|
||||
itemType: itemType,
|
||||
);
|
||||
|
||||
@@ -81,7 +81,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
_scrollController.addListener(_onScroll);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// Use extensionId if available, otherwise detect from albumId prefix
|
||||
final providerId =
|
||||
widget.extensionId ??
|
||||
(() {
|
||||
@@ -95,7 +94,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
.recordAlbumAccess(
|
||||
id: widget.albumId,
|
||||
name: widget.albumName,
|
||||
artistName: widget.tracks?.firstOrNull?.artistName,
|
||||
artistName: widget.artistName ?? widget.tracks?.firstOrNull?.albumArtist ?? widget.tracks?.firstOrNull?.artistName,
|
||||
imageUrl: widget.coverUrl,
|
||||
providerId: providerId,
|
||||
);
|
||||
@@ -134,9 +133,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
return (mediaSize.height * 0.55).clamp(360.0, 520.0);
|
||||
}
|
||||
|
||||
/// Upgrade cover URL to a reasonable resolution for full-screen display.
|
||||
/// Spotify CDN only has 300, 640, ~2000 — we stay at 640 (no intermediate).
|
||||
/// Deezer CDN: upgrade to 1000x1000 (available: 56, 250, 500, 1000, 1400, 1800).
|
||||
/// Upgrade cover URL to a higher resolution for full-screen display.
|
||||
String? _highResCoverUrl(String? url) {
|
||||
if (url == null) return null;
|
||||
// Spotify CDN: upgrade 300 → 640 only (no intermediate between 640 and 2000)
|
||||
@@ -283,7 +280,10 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
) {
|
||||
final expandedHeight = _calculateExpandedHeight(context);
|
||||
final tracks = _tracks ?? [];
|
||||
final artistName = tracks.isNotEmpty ? tracks.first.artistName : null;
|
||||
final artistName = widget.artistName ??
|
||||
(tracks.isNotEmpty
|
||||
? (tracks.first.albumArtist ?? tracks.first.artistName)
|
||||
: null);
|
||||
final releaseDate = tracks.isNotEmpty ? tracks.first.releaseDate : null;
|
||||
|
||||
return SliverAppBar(
|
||||
@@ -516,7 +516,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
}
|
||||
|
||||
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
|
||||
// Info is now displayed in the full-screen cover overlay
|
||||
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
||||
}
|
||||
|
||||
@@ -571,37 +570,74 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
void _downloadAll(BuildContext context) {
|
||||
final tracks = _tracks;
|
||||
if (tracks == null || tracks.isEmpty) return;
|
||||
|
||||
// Skip already-downloaded tracks
|
||||
final historyState = ref.read(downloadHistoryProvider);
|
||||
final settings = ref.read(settingsProvider);
|
||||
final localLibState = (settings.localLibraryEnabled && settings.localLibraryShowDuplicates)
|
||||
? ref.read(localLibraryProvider)
|
||||
: null;
|
||||
final tracksToQueue = <Track>[];
|
||||
int skippedCount = 0;
|
||||
|
||||
for (final track in tracks) {
|
||||
final isInHistory = historyState.isDownloaded(track.id) ||
|
||||
(track.isrc != null && historyState.getByIsrc(track.isrc!) != null) ||
|
||||
historyState.findByTrackAndArtist(track.name, track.artistName) != null;
|
||||
final isInLocal = localLibState?.existsInLibrary(
|
||||
isrc: track.isrc,
|
||||
trackName: track.name,
|
||||
artistName: track.artistName,
|
||||
) ??
|
||||
false;
|
||||
|
||||
if (isInHistory || isInLocal) {
|
||||
skippedCount++;
|
||||
} else {
|
||||
tracksToQueue.add(track);
|
||||
}
|
||||
}
|
||||
|
||||
if (tracksToQueue.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.discographySkippedDownloaded(0, skippedCount),
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (settings.askQualityBeforeDownload) {
|
||||
DownloadServicePicker.show(
|
||||
context,
|
||||
trackName: '${tracks.length} tracks',
|
||||
trackName: '${tracksToQueue.length} tracks',
|
||||
artistName: widget.albumName,
|
||||
onSelect: (quality, service) {
|
||||
ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
.addMultipleToQueue(tracks, service, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.snackbarAddedTracksToQueue(tracks.length),
|
||||
),
|
||||
),
|
||||
);
|
||||
.addMultipleToQueue(tracksToQueue, service, qualityOverride: quality);
|
||||
_showQueuedSnackbar(context, tracksToQueue.length, skippedCount);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
.addMultipleToQueue(tracks, settings.defaultService);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)),
|
||||
),
|
||||
);
|
||||
.addMultipleToQueue(tracksToQueue, settings.defaultService);
|
||||
_showQueuedSnackbar(context, tracksToQueue.length, skippedCount);
|
||||
}
|
||||
}
|
||||
|
||||
void _showQueuedSnackbar(BuildContext context, int added, int skipped) {
|
||||
final message = skipped > 0
|
||||
? context.l10n.discographySkippedDownloaded(added, skipped)
|
||||
: context.l10n.snackbarAddedTracksToQueue(added);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message)),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoveAllButton() {
|
||||
final collectionsState = ref.watch(libraryCollectionsProvider);
|
||||
final tracks = _tracks;
|
||||
|
||||
@@ -910,8 +910,44 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
BuildContext context,
|
||||
List<DownloadHistoryItem> allTracks,
|
||||
) {
|
||||
String selectedFormat = 'MP3';
|
||||
String selectedBitrate = '320k';
|
||||
final tracksById = {for (final t in allTracks) t.id: t};
|
||||
final sourceFormats = <String>{};
|
||||
for (final id in _selectedIds) {
|
||||
final item = tracksById[id];
|
||||
if (item == null) continue;
|
||||
final nameToCheck =
|
||||
(item.safFileName != null && item.safFileName!.isNotEmpty)
|
||||
? item.safFileName!.toLowerCase()
|
||||
: item.filePath.toLowerCase();
|
||||
final ext = nameToCheck.endsWith('.flac')
|
||||
? 'FLAC'
|
||||
: nameToCheck.endsWith('.m4a')
|
||||
? 'M4A'
|
||||
: nameToCheck.endsWith('.mp3')
|
||||
? 'MP3'
|
||||
: (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg'))
|
||||
? 'Opus'
|
||||
: null;
|
||||
if (ext != null) sourceFormats.add(ext);
|
||||
}
|
||||
|
||||
final formats = ['ALAC', 'FLAC', 'MP3', 'Opus'].where((target) {
|
||||
return sourceFormats.any((src) {
|
||||
if (src == target) return false;
|
||||
final isLosslessTarget = target == 'ALAC' || target == 'FLAC';
|
||||
final isLosslessSource = src == 'FLAC' || src == 'M4A';
|
||||
if (isLosslessTarget && !isLosslessSource) return false;
|
||||
return true;
|
||||
});
|
||||
}).toList();
|
||||
|
||||
if (formats.isEmpty) return;
|
||||
|
||||
String selectedFormat = formats.first;
|
||||
bool isLosslessTarget =
|
||||
selectedFormat == 'ALAC' || selectedFormat == 'FLAC';
|
||||
String selectedBitrate =
|
||||
isLosslessTarget ? '320k' : (selectedFormat == 'Opus' ? '128k' : '320k');
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
@@ -923,7 +959,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setSheetState) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final formats = ['MP3', 'Opus'];
|
||||
final bitrates = ['128k', '192k', '256k', '320k'];
|
||||
|
||||
return SafeArea(
|
||||
@@ -960,51 +995,75 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: formats.map((format) {
|
||||
final isSelected = format == selectedFormat;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ChoiceChip(
|
||||
label: Text(format),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
setSheetState(() {
|
||||
selectedFormat = format;
|
||||
selectedBitrate = format == 'Opus'
|
||||
? '128k'
|
||||
: '320k';
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
context.l10n.trackConvertBitrate,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: bitrates.map((br) {
|
||||
final isSelected = br == selectedBitrate;
|
||||
children: formats.map((format) {
|
||||
final isSelected = format == selectedFormat;
|
||||
return ChoiceChip(
|
||||
label: Text(br),
|
||||
label: Text(format),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
setSheetState(() => selectedBitrate = br);
|
||||
setSheetState(() {
|
||||
selectedFormat = format;
|
||||
isLosslessTarget =
|
||||
format == 'ALAC' || format == 'FLAC';
|
||||
if (!isLosslessTarget) {
|
||||
selectedBitrate =
|
||||
format == 'Opus' ? '128k' : '320k';
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
if (!isLosslessTarget) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
context.l10n.trackConvertBitrate,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: bitrates.map((br) {
|
||||
final isSelected = br == selectedBitrate;
|
||||
return ChoiceChip(
|
||||
label: Text(br),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
setSheetState(() => selectedBitrate = br);
|
||||
}
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
if (isLosslessTarget) ...[
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.verified,
|
||||
size: 16,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
context.l10n.trackConvertLosslessHint,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
@@ -1057,12 +1116,19 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
: item.filePath.toLowerCase();
|
||||
final ext = nameToCheck.endsWith('.flac')
|
||||
? 'FLAC'
|
||||
: nameToCheck.endsWith('.m4a')
|
||||
? 'M4A'
|
||||
: nameToCheck.endsWith('.mp3')
|
||||
? 'MP3'
|
||||
: (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg'))
|
||||
? 'Opus'
|
||||
: null;
|
||||
if (ext != null && ext != targetFormat) selected.add(item);
|
||||
if (ext == null || ext == targetFormat) continue;
|
||||
// Skip lossy sources when target is lossless (pointless re-encoding)
|
||||
final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC';
|
||||
final isLosslessSource = ext == 'FLAC' || ext == 'M4A';
|
||||
if (isLosslessTarget && !isLosslessSource) continue;
|
||||
selected.add(item);
|
||||
}
|
||||
|
||||
if (selected.isEmpty) {
|
||||
@@ -1074,16 +1140,22 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
final isLossless = targetFormat == 'ALAC' || targetFormat == 'FLAC';
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(context.l10n.selectionBatchConvertConfirmTitle),
|
||||
content: Text(
|
||||
context.l10n.selectionBatchConvertConfirmMessage(
|
||||
selected.length,
|
||||
targetFormat,
|
||||
bitrate,
|
||||
),
|
||||
isLossless
|
||||
? context.l10n.selectionBatchConvertConfirmMessageLossless(
|
||||
selected.length,
|
||||
targetFormat,
|
||||
)
|
||||
: context.l10n.selectionBatchConvertConfirmMessage(
|
||||
selected.length,
|
||||
targetFormat,
|
||||
bitrate,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
@@ -1103,8 +1175,10 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
int successCount = 0;
|
||||
final total = selected.length;
|
||||
final historyDb = HistoryDatabase.instance;
|
||||
final newQuality =
|
||||
'${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}';
|
||||
final newQuality = (targetFormat.toUpperCase() == 'ALAC' ||
|
||||
targetFormat.toUpperCase() == 'FLAC')
|
||||
? '${targetFormat.toUpperCase()} Lossless'
|
||||
: '${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}';
|
||||
final settings = ref.read(settingsProvider);
|
||||
final shouldEmbedLyrics =
|
||||
settings.embedLyrics && settings.lyricsMode != 'external';
|
||||
@@ -1207,13 +1281,27 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
final baseName = dotIdx > 0
|
||||
? oldFileName.substring(0, dotIdx)
|
||||
: oldFileName;
|
||||
final newExt = targetFormat.toLowerCase() == 'opus'
|
||||
? '.opus'
|
||||
: '.mp3';
|
||||
String newExt;
|
||||
String mimeType;
|
||||
switch (targetFormat.toLowerCase()) {
|
||||
case 'opus':
|
||||
newExt = '.opus';
|
||||
mimeType = 'audio/opus';
|
||||
break;
|
||||
case 'alac':
|
||||
newExt = '.m4a';
|
||||
mimeType = 'audio/mp4';
|
||||
break;
|
||||
case 'flac':
|
||||
newExt = '.flac';
|
||||
mimeType = 'audio/flac';
|
||||
break;
|
||||
default:
|
||||
newExt = '.mp3';
|
||||
mimeType = 'audio/mpeg';
|
||||
break;
|
||||
}
|
||||
final newFileName = '$baseName$newExt';
|
||||
final mimeType = targetFormat.toLowerCase() == 'opus'
|
||||
? 'audio/opus'
|
||||
: 'audio/mpeg';
|
||||
|
||||
final safUri = await PlatformBridge.createSafFileFromPath(
|
||||
treeUri: treeUri,
|
||||
|
||||
@@ -3909,6 +3909,7 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
|
||||
name: (data['name'] ?? '').toString(),
|
||||
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
|
||||
albumName: (data['album_name'] ?? widget.albumName).toString(),
|
||||
albumArtist: (data['album_artist'] ?? _artistName)?.toString(),
|
||||
artistId:
|
||||
(data['artist_id'] ?? data['artistId'])?.toString() ?? _artistId,
|
||||
albumId: data['album_id']?.toString() ?? widget.albumId,
|
||||
|
||||
@@ -39,6 +39,7 @@ class _LibraryTracksFolderScreenState
|
||||
|
||||
bool _isSelectionMode = false;
|
||||
final Set<String> _selectedKeys = {};
|
||||
UserPlaylistCollection? playlist;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -243,7 +244,6 @@ class _LibraryTracksFolderScreenState
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
ref.watch(localLibraryProvider.select((s) => s.items));
|
||||
final localState = ref.read(localLibraryProvider);
|
||||
final UserPlaylistCollection? playlist;
|
||||
final List<CollectionTrackEntry> entries;
|
||||
|
||||
switch (widget.mode) {
|
||||
@@ -872,11 +872,54 @@ class _LibraryTracksFolderScreenState
|
||||
|
||||
void _downloadAll(List<Track> tracks) {
|
||||
if (tracks.isEmpty) return;
|
||||
final historyState = ref.read(downloadHistoryProvider);
|
||||
final settings = ref.read(settingsProvider);
|
||||
final localLibState =
|
||||
(settings.localLibraryEnabled && settings.localLibraryShowDuplicates)
|
||||
? ref.read(localLibraryProvider)
|
||||
: null;
|
||||
final playlistName = widget.mode == LibraryTracksFolderMode.playlist
|
||||
? playlist?.name ?? context.l10n.collectionPlaylist
|
||||
: null;
|
||||
final tracksToQueue = <Track>[];
|
||||
var skippedCount = 0;
|
||||
|
||||
for (final track in tracks) {
|
||||
final isInHistory =
|
||||
historyState.isDownloaded(track.id) ||
|
||||
(track.isrc != null && historyState.getByIsrc(track.isrc!) != null) ||
|
||||
historyState.findByTrackAndArtist(track.name, track.artistName) !=
|
||||
null;
|
||||
final isInLocal =
|
||||
localLibState?.existsInLibrary(
|
||||
isrc: track.isrc,
|
||||
trackName: track.name,
|
||||
artistName: track.artistName,
|
||||
) ??
|
||||
false;
|
||||
|
||||
if (isInHistory || isInLocal) {
|
||||
skippedCount++;
|
||||
} else {
|
||||
tracksToQueue.add(track);
|
||||
}
|
||||
}
|
||||
|
||||
if (tracksToQueue.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.discographySkippedDownloaded(0, skippedCount),
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (settings.askQualityBeforeDownload) {
|
||||
DownloadServicePicker.show(
|
||||
context,
|
||||
trackName: '${tracks.length} tracks',
|
||||
trackName: '${tracksToQueue.length} tracks',
|
||||
artistName: switch (widget.mode) {
|
||||
LibraryTracksFolderMode.wishlist => context.l10n.collectionWishlist,
|
||||
LibraryTracksFolderMode.loved => context.l10n.collectionLoved,
|
||||
@@ -885,12 +928,24 @@ class _LibraryTracksFolderScreenState
|
||||
onSelect: (quality, service) {
|
||||
ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
.addMultipleToQueue(tracks, service, qualityOverride: quality);
|
||||
.addMultipleToQueue(
|
||||
tracksToQueue,
|
||||
service,
|
||||
qualityOverride: quality,
|
||||
playlistName: playlistName,
|
||||
);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.snackbarAddedTracksToQueue(tracks.length),
|
||||
skippedCount > 0
|
||||
? context.l10n.discographySkippedDownloaded(
|
||||
tracksToQueue.length,
|
||||
skippedCount,
|
||||
)
|
||||
: context.l10n.snackbarAddedTracksToQueue(
|
||||
tracksToQueue.length,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -899,10 +954,21 @@ class _LibraryTracksFolderScreenState
|
||||
} else {
|
||||
ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
.addMultipleToQueue(tracks, settings.defaultService);
|
||||
.addMultipleToQueue(
|
||||
tracksToQueue,
|
||||
settings.defaultService,
|
||||
playlistName: playlistName,
|
||||
);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)),
|
||||
content: Text(
|
||||
skippedCount > 0
|
||||
? context.l10n.discographySkippedDownloaded(
|
||||
tracksToQueue.length,
|
||||
skippedCount,
|
||||
)
|
||||
: context.l10n.snackbarAddedTracksToQueue(tracksToQueue.length),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,11 +4,15 @@ import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
|
||||
import 'package:spotiflac_android/services/library_database.dart';
|
||||
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
||||
import 'package:spotiflac_android/services/local_track_redownload_service.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
import 'package:spotiflac_android/providers/playback_provider.dart';
|
||||
@@ -41,11 +45,10 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
|
||||
void _showCueVirtualTrackSnackBar() {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(cueVirtualTrackRequiresSplitMessage),
|
||||
),
|
||||
const SnackBar(content: Text(cueVirtualTrackRequiresSplitMessage)),
|
||||
);
|
||||
}
|
||||
|
||||
late List<int> _sortedDiscNumbersCache;
|
||||
late bool _hasMultipleDiscsCache;
|
||||
String? _commonQualityCache;
|
||||
@@ -623,11 +626,13 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
|
||||
slivers.add(
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) =>
|
||||
_buildTrackItem(context, colorScheme, discTracks[index]),
|
||||
childCount: discTracks.length,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final track = discTracks[index];
|
||||
return KeyedSubtree(
|
||||
key: ValueKey(track.id),
|
||||
child: _buildTrackItem(context, colorScheme, track),
|
||||
);
|
||||
}, childCount: discTracks.length),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -897,6 +902,128 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
return false;
|
||||
}
|
||||
|
||||
List<LocalLibraryItem> _selectedFlacEligibleItems(
|
||||
List<LocalLibraryItem> allTracks,
|
||||
) {
|
||||
final tracksById = {for (final t in allTracks) t.id: t};
|
||||
return _selectedIds
|
||||
.map((id) => tracksById[id])
|
||||
.whereType<LocalLibraryItem>()
|
||||
.where(LocalTrackRedownloadService.isFlacUpgradeEligible)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<void> _queueSelectedAsFlac(List<LocalLibraryItem> allTracks) async {
|
||||
final selected = _selectedFlacEligibleItems(allTracks);
|
||||
|
||||
if (selected.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(context.l10n.queueFlacAction),
|
||||
content: Text(context.l10n.queueFlacConfirmMessage(selected.length)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: Text(context.l10n.queueFlacAction),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed != true || !mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final settings = ref.read(settingsProvider);
|
||||
final extensionState = ref.read(extensionProvider);
|
||||
final includeExtensions =
|
||||
settings.useExtensionProviders &&
|
||||
extensionState.extensions.any(
|
||||
(ext) => ext.enabled && ext.hasMetadataProvider,
|
||||
);
|
||||
final targetService = LocalTrackRedownloadService.preferredFlacService(
|
||||
settings,
|
||||
);
|
||||
final targetQuality =
|
||||
LocalTrackRedownloadService.preferredFlacQualityForService(
|
||||
targetService,
|
||||
);
|
||||
|
||||
final matchedTracks = <Track>[];
|
||||
var skippedCount = 0;
|
||||
final total = selected.length;
|
||||
|
||||
for (var i = 0; i < total; i++) {
|
||||
if (!mounted) break;
|
||||
|
||||
ScaffoldMessenger.of(context).clearSnackBars();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.queueFlacFindingProgress(i + 1, total)),
|
||||
duration: const Duration(seconds: 30),
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
final resolution = await LocalTrackRedownloadService.resolveBestMatch(
|
||||
selected[i],
|
||||
includeExtensions: includeExtensions,
|
||||
);
|
||||
if (resolution.canQueue && resolution.match != null) {
|
||||
matchedTracks.add(resolution.match!);
|
||||
} else {
|
||||
skippedCount++;
|
||||
}
|
||||
} catch (_) {
|
||||
skippedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
ScaffoldMessenger.of(context).clearSnackBars();
|
||||
|
||||
if (matchedTracks.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.queueFlacNoReliableMatches)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
.addMultipleToQueue(
|
||||
matchedTracks,
|
||||
targetService,
|
||||
qualityOverride: targetQuality,
|
||||
);
|
||||
|
||||
final summary = skippedCount == 0
|
||||
? context.l10n.snackbarAddedTracksToQueue(matchedTracks.length)
|
||||
: context.l10n.queueFlacQueuedWithSkipped(
|
||||
matchedTracks.length,
|
||||
skippedCount,
|
||||
);
|
||||
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(summary)));
|
||||
setState(() {
|
||||
_selectedIds.clear();
|
||||
_isSelectionMode = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _reEnrichSelected(List<LocalLibraryItem> allTracks) async {
|
||||
final tracksById = {for (final t in allTracks) t.id: t};
|
||||
final selected = <LocalLibraryItem>[];
|
||||
@@ -1005,8 +1132,57 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
BuildContext context,
|
||||
List<LocalLibraryItem> allTracks,
|
||||
) {
|
||||
String selectedFormat = 'MP3';
|
||||
String selectedBitrate = '320k';
|
||||
final tracksById = {for (final t in allTracks) t.id: t};
|
||||
final sourceFormats = <String>{};
|
||||
for (final id in _selectedIds) {
|
||||
final item = tracksById[id];
|
||||
if (item == null) continue;
|
||||
String? ext;
|
||||
if (item.format != null && item.format!.isNotEmpty) {
|
||||
final fmt = item.format!.toLowerCase();
|
||||
if (fmt == 'flac') {
|
||||
ext = 'FLAC';
|
||||
} else if (fmt == 'm4a') {
|
||||
ext = 'M4A';
|
||||
} else if (fmt == 'mp3') {
|
||||
ext = 'MP3';
|
||||
} else if (fmt == 'opus' || fmt == 'ogg') {
|
||||
ext = 'Opus';
|
||||
}
|
||||
}
|
||||
if (ext == null) {
|
||||
final lower = item.filePath.toLowerCase();
|
||||
if (lower.endsWith('.flac')) {
|
||||
ext = 'FLAC';
|
||||
} else if (lower.endsWith('.m4a')) {
|
||||
ext = 'M4A';
|
||||
} else if (lower.endsWith('.mp3')) {
|
||||
ext = 'MP3';
|
||||
} else if (lower.endsWith('.opus') || lower.endsWith('.ogg')) {
|
||||
ext = 'Opus';
|
||||
}
|
||||
}
|
||||
if (ext != null) sourceFormats.add(ext);
|
||||
}
|
||||
|
||||
final formats = ['ALAC', 'FLAC', 'MP3', 'Opus'].where((target) {
|
||||
return sourceFormats.any((src) {
|
||||
if (src == target) return false;
|
||||
final isLosslessTarget = target == 'ALAC' || target == 'FLAC';
|
||||
final isLosslessSource = src == 'FLAC' || src == 'M4A';
|
||||
if (isLosslessTarget && !isLosslessSource) return false;
|
||||
return true;
|
||||
});
|
||||
}).toList();
|
||||
|
||||
if (formats.isEmpty) return;
|
||||
|
||||
String selectedFormat = formats.first;
|
||||
bool isLosslessTarget =
|
||||
selectedFormat == 'ALAC' || selectedFormat == 'FLAC';
|
||||
String selectedBitrate = isLosslessTarget
|
||||
? '320k'
|
||||
: (selectedFormat == 'Opus' ? '128k' : '320k');
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
@@ -1018,7 +1194,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setSheetState) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final formats = ['MP3', 'Opus'];
|
||||
final bitrates = ['128k', '192k', '256k', '320k'];
|
||||
|
||||
return SafeArea(
|
||||
@@ -1055,51 +1230,73 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: formats.map((format) {
|
||||
final isSelected = format == selectedFormat;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ChoiceChip(
|
||||
label: Text(format),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
setSheetState(() {
|
||||
selectedFormat = format;
|
||||
selectedBitrate = format == 'Opus'
|
||||
? '128k'
|
||||
: '320k';
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
context.l10n.trackConvertBitrate,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: bitrates.map((br) {
|
||||
final isSelected = br == selectedBitrate;
|
||||
children: formats.map((format) {
|
||||
final isSelected = format == selectedFormat;
|
||||
return ChoiceChip(
|
||||
label: Text(br),
|
||||
label: Text(format),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
setSheetState(() => selectedBitrate = br);
|
||||
setSheetState(() {
|
||||
selectedFormat = format;
|
||||
isLosslessTarget =
|
||||
format == 'ALAC' || format == 'FLAC';
|
||||
if (!isLosslessTarget) {
|
||||
selectedBitrate = format == 'Opus'
|
||||
? '128k'
|
||||
: '320k';
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
if (!isLosslessTarget) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
context.l10n.trackConvertBitrate,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: bitrates.map((br) {
|
||||
final isSelected = br == selectedBitrate;
|
||||
return ChoiceChip(
|
||||
label: Text(br),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
setSheetState(() => selectedBitrate = br);
|
||||
}
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
if (isLosslessTarget) ...[
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.verified,
|
||||
size: 16,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
context.l10n.trackConvertLosslessHint,
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(color: colorScheme.primary),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
@@ -1152,6 +1349,8 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
final fmt = item.format!.toLowerCase();
|
||||
if (fmt == 'flac') {
|
||||
currentFormat = 'FLAC';
|
||||
} else if (fmt == 'm4a') {
|
||||
currentFormat = 'M4A';
|
||||
} else if (fmt == 'mp3') {
|
||||
currentFormat = 'MP3';
|
||||
} else if (fmt == 'opus' || fmt == 'ogg') {
|
||||
@@ -1163,15 +1362,21 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
final lower = item.filePath.toLowerCase();
|
||||
if (lower.endsWith('.flac')) {
|
||||
currentFormat = 'FLAC';
|
||||
} else if (lower.endsWith('.m4a')) {
|
||||
currentFormat = 'M4A';
|
||||
} else if (lower.endsWith('.mp3')) {
|
||||
currentFormat = 'MP3';
|
||||
} else if (lower.endsWith('.opus') || lower.endsWith('.ogg')) {
|
||||
currentFormat = 'Opus';
|
||||
}
|
||||
}
|
||||
if (currentFormat != null && currentFormat != targetFormat) {
|
||||
selected.add(item);
|
||||
}
|
||||
if (currentFormat == null || currentFormat == targetFormat) continue;
|
||||
// Skip lossy sources when target is lossless (pointless re-encoding)
|
||||
final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC';
|
||||
final isLosslessSource =
|
||||
currentFormat == 'FLAC' || currentFormat == 'M4A';
|
||||
if (isLosslessTarget && !isLosslessSource) continue;
|
||||
selected.add(item);
|
||||
}
|
||||
|
||||
if (selected.isEmpty) {
|
||||
@@ -1183,16 +1388,22 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
final isLossless = targetFormat == 'ALAC' || targetFormat == 'FLAC';
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(context.l10n.selectionBatchConvertConfirmTitle),
|
||||
content: Text(
|
||||
context.l10n.selectionBatchConvertConfirmMessage(
|
||||
selected.length,
|
||||
targetFormat,
|
||||
bitrate,
|
||||
),
|
||||
isLossless
|
||||
? context.l10n.selectionBatchConvertConfirmMessageLossless(
|
||||
selected.length,
|
||||
targetFormat,
|
||||
)
|
||||
: context.l10n.selectionBatchConvertConfirmMessage(
|
||||
selected.length,
|
||||
targetFormat,
|
||||
bitrate,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
@@ -1357,13 +1568,27 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
final baseName = dotIdx > 0
|
||||
? oldFileName.substring(0, dotIdx)
|
||||
: oldFileName;
|
||||
final newExt = targetFormat.toLowerCase() == 'opus'
|
||||
? '.opus'
|
||||
: '.mp3';
|
||||
String newExt;
|
||||
String mimeType;
|
||||
switch (targetFormat.toLowerCase()) {
|
||||
case 'opus':
|
||||
newExt = '.opus';
|
||||
mimeType = 'audio/opus';
|
||||
break;
|
||||
case 'alac':
|
||||
newExt = '.m4a';
|
||||
mimeType = 'audio/mp4';
|
||||
break;
|
||||
case 'flac':
|
||||
newExt = '.flac';
|
||||
mimeType = 'audio/flac';
|
||||
break;
|
||||
default:
|
||||
newExt = '.mp3';
|
||||
mimeType = 'audio/mpeg';
|
||||
break;
|
||||
}
|
||||
final newFileName = '$baseName$newExt';
|
||||
final mimeType = targetFormat.toLowerCase() == 'opus'
|
||||
? 'audio/opus'
|
||||
: 'audio/mpeg';
|
||||
|
||||
final safUri = await PlatformBridge.createSafFileFromPath(
|
||||
treeUri: treeUri,
|
||||
@@ -1434,6 +1659,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
double bottomPadding,
|
||||
) {
|
||||
final selectedCount = _selectedIds.length;
|
||||
final flacEligibleCount = _selectedFlacEligibleItems(tracks).length;
|
||||
final allSelected = selectedCount == tracks.length && tracks.isNotEmpty;
|
||||
|
||||
return Container(
|
||||
@@ -1525,6 +1751,18 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
|
||||
Row(
|
||||
children: [
|
||||
if (flacEligibleCount > 0) ...[
|
||||
Expanded(
|
||||
child: _LocalAlbumSelectionActionButton(
|
||||
icon: Icons.download_for_offline_outlined,
|
||||
label:
|
||||
'${context.l10n.queueFlacAction} ($flacEligibleCount)',
|
||||
onPressed: () => _queueSelectedAsFlac(tracks),
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Expanded(
|
||||
child: _LocalAlbumSelectionActionButton(
|
||||
icon: Icons.auto_fix_high_outlined,
|
||||
|
||||
@@ -83,7 +83,6 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
final extState = ref.read(extensionProvider);
|
||||
if (!extState.isInitialized) {
|
||||
_log.d('Waiting for extensions to initialize before handling URL...');
|
||||
// Wait up to 5 seconds for extensions to initialize
|
||||
for (int i = 0; i < 50; i++) {
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
if (!mounted) return;
|
||||
|
||||
@@ -350,7 +350,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
}
|
||||
|
||||
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
|
||||
// Info is now displayed in the full-screen cover overlay
|
||||
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
||||
}
|
||||
|
||||
@@ -533,7 +532,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
tooltip: context.l10n.tooltipAddToPlaylist,
|
||||
onPressed: _tracks.isEmpty
|
||||
? null
|
||||
: () => showAddTracksToPlaylistSheet(context, ref, _tracks),
|
||||
: () => showAddTracksToPlaylistSheet(context, ref, _tracks, playlistNamePrefill: widget.playlistName),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -608,45 +607,82 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
|
||||
void _downloadTracks(BuildContext context, List<Track> tracks) {
|
||||
if (tracks.isEmpty) return;
|
||||
|
||||
// Skip already-downloaded tracks
|
||||
final historyState = ref.read(downloadHistoryProvider);
|
||||
final settings = ref.read(settingsProvider);
|
||||
final localLibState = (settings.localLibraryEnabled && settings.localLibraryShowDuplicates)
|
||||
? ref.read(localLibraryProvider)
|
||||
: null;
|
||||
final tracksToQueue = <Track>[];
|
||||
int skippedCount = 0;
|
||||
|
||||
for (final track in tracks) {
|
||||
final isInHistory = historyState.isDownloaded(track.id) ||
|
||||
(track.isrc != null && historyState.getByIsrc(track.isrc!) != null) ||
|
||||
historyState.findByTrackAndArtist(track.name, track.artistName) != null;
|
||||
final isInLocal = localLibState?.existsInLibrary(
|
||||
isrc: track.isrc,
|
||||
trackName: track.name,
|
||||
artistName: track.artistName,
|
||||
) ??
|
||||
false;
|
||||
|
||||
if (isInHistory || isInLocal) {
|
||||
skippedCount++;
|
||||
} else {
|
||||
tracksToQueue.add(track);
|
||||
}
|
||||
}
|
||||
|
||||
if (tracksToQueue.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.discographySkippedDownloaded(0, skippedCount),
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (settings.askQualityBeforeDownload) {
|
||||
DownloadServicePicker.show(
|
||||
context,
|
||||
trackName: '${tracks.length} tracks',
|
||||
trackName: '${tracksToQueue.length} tracks',
|
||||
artistName: _playlistName,
|
||||
onSelect: (quality, service) {
|
||||
ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
.addMultipleToQueue(
|
||||
tracks,
|
||||
tracksToQueue,
|
||||
service,
|
||||
qualityOverride: quality,
|
||||
playlistName: _playlistName,
|
||||
);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.snackbarAddedTracksToQueue(tracks.length),
|
||||
),
|
||||
),
|
||||
);
|
||||
_showQueuedSnackbar(context, tracksToQueue.length, skippedCount);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
.addMultipleToQueue(
|
||||
tracks,
|
||||
tracksToQueue,
|
||||
settings.defaultService,
|
||||
playlistName: _playlistName,
|
||||
);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)),
|
||||
),
|
||||
);
|
||||
_showQueuedSnackbar(context, tracksToQueue.length, skippedCount);
|
||||
}
|
||||
}
|
||||
|
||||
void _showQueuedSnackbar(BuildContext context, int added, int skipped) {
|
||||
final message = skipped > 0
|
||||
? context.l10n.discographySkippedDownloaded(added, skipped)
|
||||
: context.l10n.snackbarAddedTracksToQueue(added);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Separate Consumer widget for each track - only rebuilds when this specific track's status changes
|
||||
|
||||
+407
-57
@@ -17,11 +17,13 @@ import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
|
||||
import 'package:spotiflac_android/models/download_item.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
import 'package:spotiflac_android/providers/playback_provider.dart';
|
||||
import 'package:spotiflac_android/services/library_database.dart';
|
||||
import 'package:spotiflac_android/services/local_track_redownload_service.dart';
|
||||
import 'package:spotiflac_android/services/history_database.dart';
|
||||
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
|
||||
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||
@@ -31,6 +33,7 @@ import 'package:spotiflac_android/screens/local_album_screen.dart';
|
||||
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
||||
import 'package:spotiflac_android/utils/path_match_keys.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||
|
||||
enum LibraryItemSource { downloaded, local }
|
||||
|
||||
@@ -1314,6 +1317,94 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _downloadAllSelectedPlaylists(BuildContext context) async {
|
||||
final collectionsState = ref.read(libraryCollectionsProvider);
|
||||
final selectedPlaylists = collectionsState.playlists
|
||||
.where((p) => _selectedPlaylistIds.contains(p.id))
|
||||
.toList();
|
||||
|
||||
final totalTracks = selectedPlaylists.fold<int>(
|
||||
0,
|
||||
(sum, p) => sum + p.tracks.length,
|
||||
);
|
||||
|
||||
if (totalTracks == 0) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.snackbarSelectedPlaylistsEmpty)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(ctx.l10n.dialogDownloadAllTitle),
|
||||
content: Text(
|
||||
ctx.l10n.dialogDownloadPlaylistsMessage(
|
||||
totalTracks,
|
||||
selectedPlaylists.length,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: Text(ctx.l10n.dialogCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: Text(ctx.l10n.dialogDownload),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed != true || !context.mounted) return;
|
||||
|
||||
final settings = ref.read(settingsProvider);
|
||||
final queueNotifier = ref.read(downloadQueueProvider.notifier);
|
||||
|
||||
void enqueueAll({String? qualityOverride, String? service}) {
|
||||
final svc = service ?? settings.defaultService;
|
||||
for (final playlist in selectedPlaylists) {
|
||||
final tracks = playlist.tracks.map((e) => e.track).toList();
|
||||
queueNotifier.addMultipleToQueue(
|
||||
tracks,
|
||||
svc,
|
||||
qualityOverride: qualityOverride,
|
||||
playlistName: playlist.name,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.askQualityBeforeDownload) {
|
||||
DownloadServicePicker.show(
|
||||
context,
|
||||
trackName: context.l10n.tracksCount(totalTracks),
|
||||
artistName: context.l10n.playlistsCount(selectedPlaylists.length),
|
||||
onSelect: (quality, service) {
|
||||
enqueueAll(qualityOverride: quality, service: service);
|
||||
if (!mounted) return;
|
||||
_exitPlaylistSelectionMode();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.snackbarAddedTracksToQueue(totalTracks),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
enqueueAll();
|
||||
_exitPlaylistSelectionMode();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.snackbarAddedTracksToQueue(totalTracks)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteSelectedPlaylists(BuildContext context) async {
|
||||
final count = _selectedPlaylistIds.length;
|
||||
final confirmed = await showDialog<bool>(
|
||||
@@ -1452,6 +1543,37 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: selectedCount > 0
|
||||
? () => _downloadAllSelectedPlaylists(context)
|
||||
: null,
|
||||
icon: const Icon(Icons.download_rounded),
|
||||
label: Text(
|
||||
selectedCount > 0
|
||||
? context.l10n.bulkDownloadPlaylistsButton(
|
||||
selectedCount,
|
||||
)
|
||||
: context.l10n.bulkDownloadSelectPlaylists,
|
||||
),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: selectedCount > 0
|
||||
? colorScheme.primary
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
foregroundColor: selectedCount > 0
|
||||
? colorScheme.onPrimary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
@@ -4362,6 +4484,132 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
return false;
|
||||
}
|
||||
|
||||
List<LocalLibraryItem> _selectedFlacEligibleLocalItems(
|
||||
List<UnifiedLibraryItem> allItems,
|
||||
) {
|
||||
final selectedItems = _selectedItemsFromAll(allItems);
|
||||
return selectedItems
|
||||
.map((item) => item.localItem)
|
||||
.whereType<LocalLibraryItem>()
|
||||
.where(LocalTrackRedownloadService.isFlacUpgradeEligible)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<void> _queueSelectedLocalAsFlac(
|
||||
List<UnifiedLibraryItem> allItems,
|
||||
) async {
|
||||
final selectedLocalItems = _selectedFlacEligibleLocalItems(allItems);
|
||||
|
||||
if (selectedLocalItems.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(context.l10n.queueFlacAction),
|
||||
content: Text(
|
||||
context.l10n.queueFlacConfirmMessage(selectedLocalItems.length),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: Text(context.l10n.queueFlacAction),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed != true || !mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final settings = ref.read(settingsProvider);
|
||||
final extensionState = ref.read(extensionProvider);
|
||||
final includeExtensions =
|
||||
settings.useExtensionProviders &&
|
||||
extensionState.extensions.any(
|
||||
(ext) => ext.enabled && ext.hasMetadataProvider,
|
||||
);
|
||||
final targetService = LocalTrackRedownloadService.preferredFlacService(
|
||||
settings,
|
||||
);
|
||||
final targetQuality =
|
||||
LocalTrackRedownloadService.preferredFlacQualityForService(
|
||||
targetService,
|
||||
);
|
||||
|
||||
final matchedTracks = <Track>[];
|
||||
var skippedCount = 0;
|
||||
final total = selectedLocalItems.length;
|
||||
|
||||
for (var i = 0; i < total; i++) {
|
||||
if (!mounted) break;
|
||||
|
||||
ScaffoldMessenger.of(context).clearSnackBars();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.queueFlacFindingProgress(i + 1, total)),
|
||||
duration: const Duration(seconds: 30),
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
final resolution = await LocalTrackRedownloadService.resolveBestMatch(
|
||||
selectedLocalItems[i],
|
||||
includeExtensions: includeExtensions,
|
||||
);
|
||||
if (resolution.canQueue && resolution.match != null) {
|
||||
matchedTracks.add(resolution.match!);
|
||||
} else {
|
||||
skippedCount++;
|
||||
}
|
||||
} catch (_) {
|
||||
skippedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
ScaffoldMessenger.of(context).clearSnackBars();
|
||||
|
||||
if (matchedTracks.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.queueFlacNoReliableMatches)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
.addMultipleToQueue(
|
||||
matchedTracks,
|
||||
targetService,
|
||||
qualityOverride: targetQuality,
|
||||
);
|
||||
|
||||
final summary = skippedCount == 0
|
||||
? context.l10n.snackbarAddedTracksToQueue(matchedTracks.length)
|
||||
: context.l10n.queueFlacQueuedWithSkipped(
|
||||
matchedTracks.length,
|
||||
skippedCount,
|
||||
);
|
||||
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(summary)));
|
||||
setState(() {
|
||||
_selectedIds.clear();
|
||||
_isSelectionMode = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _reEnrichSelectedLocalFromQueue(
|
||||
List<UnifiedLibraryItem> allItems,
|
||||
) async {
|
||||
@@ -4512,8 +4760,51 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
BuildContext context,
|
||||
List<UnifiedLibraryItem> allItems,
|
||||
) async {
|
||||
String selectedFormat = 'MP3';
|
||||
String selectedBitrate = '320k';
|
||||
final itemsById = {for (final item in allItems) item.id: item};
|
||||
final sourceFormats = <String>{};
|
||||
for (final id in _selectedIds) {
|
||||
final item = itemsById[id];
|
||||
if (item == null) continue;
|
||||
String nameToCheck;
|
||||
if (item.historyItem?.safFileName != null &&
|
||||
item.historyItem!.safFileName!.isNotEmpty) {
|
||||
nameToCheck = item.historyItem!.safFileName!.toLowerCase();
|
||||
} else if (item.localItem?.format != null &&
|
||||
item.localItem!.format!.isNotEmpty) {
|
||||
nameToCheck = '.${item.localItem!.format!.toLowerCase()}';
|
||||
} else {
|
||||
nameToCheck = item.filePath.toLowerCase();
|
||||
}
|
||||
final ext = nameToCheck.endsWith('.flac')
|
||||
? 'FLAC'
|
||||
: nameToCheck.endsWith('.m4a')
|
||||
? 'M4A'
|
||||
: nameToCheck.endsWith('.mp3')
|
||||
? 'MP3'
|
||||
: (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg'))
|
||||
? 'Opus'
|
||||
: null;
|
||||
if (ext != null) sourceFormats.add(ext);
|
||||
}
|
||||
|
||||
final formats = ['ALAC', 'FLAC', 'MP3', 'Opus'].where((target) {
|
||||
return sourceFormats.any((src) {
|
||||
if (src == target) return false;
|
||||
final isLosslessTarget = target == 'ALAC' || target == 'FLAC';
|
||||
final isLosslessSource = src == 'FLAC' || src == 'M4A';
|
||||
if (isLosslessTarget && !isLosslessSource) return false;
|
||||
return true;
|
||||
});
|
||||
}).toList();
|
||||
|
||||
if (formats.isEmpty) return;
|
||||
|
||||
String selectedFormat = formats.first;
|
||||
bool isLosslessTarget =
|
||||
selectedFormat == 'ALAC' || selectedFormat == 'FLAC';
|
||||
String selectedBitrate = isLosslessTarget
|
||||
? '320k'
|
||||
: (selectedFormat == 'Opus' ? '128k' : '320k');
|
||||
var didStartConversion = false;
|
||||
|
||||
_hideSelectionOverlay();
|
||||
@@ -4529,7 +4820,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setSheetState) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final formats = ['MP3', 'Opus'];
|
||||
final bitrates = ['128k', '192k', '256k', '320k'];
|
||||
|
||||
return SafeArea(
|
||||
@@ -4566,51 +4856,73 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: formats.map((format) {
|
||||
final isSelected = format == selectedFormat;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ChoiceChip(
|
||||
label: Text(format),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
setSheetState(() {
|
||||
selectedFormat = format;
|
||||
selectedBitrate = format == 'Opus'
|
||||
? '128k'
|
||||
: '320k';
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
context.l10n.trackConvertBitrate,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: bitrates.map((br) {
|
||||
final isSelected = br == selectedBitrate;
|
||||
children: formats.map((format) {
|
||||
final isSelected = format == selectedFormat;
|
||||
return ChoiceChip(
|
||||
label: Text(br),
|
||||
label: Text(format),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
setSheetState(() => selectedBitrate = br);
|
||||
setSheetState(() {
|
||||
selectedFormat = format;
|
||||
isLosslessTarget =
|
||||
format == 'ALAC' || format == 'FLAC';
|
||||
if (!isLosslessTarget) {
|
||||
selectedBitrate = format == 'Opus'
|
||||
? '128k'
|
||||
: '320k';
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
if (!isLosslessTarget) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
context.l10n.trackConvertBitrate,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: bitrates.map((br) {
|
||||
final isSelected = br == selectedBitrate;
|
||||
return ChoiceChip(
|
||||
label: Text(br),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
setSheetState(() => selectedBitrate = br);
|
||||
}
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
if (isLosslessTarget) ...[
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.verified,
|
||||
size: 16,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
context.l10n.trackConvertLosslessHint,
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(color: colorScheme.primary),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
@@ -4686,14 +4998,19 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
}
|
||||
final ext = nameToCheck.endsWith('.flac')
|
||||
? 'FLAC'
|
||||
: nameToCheck.endsWith('.m4a')
|
||||
? 'M4A'
|
||||
: nameToCheck.endsWith('.mp3')
|
||||
? 'MP3'
|
||||
: (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg'))
|
||||
? 'Opus'
|
||||
: null;
|
||||
if (ext != null && ext != targetFormat) {
|
||||
selectedItems.add(item);
|
||||
}
|
||||
if (ext == null || ext == targetFormat) continue;
|
||||
// Skip lossy sources when target is lossless (pointless re-encoding)
|
||||
final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC';
|
||||
final isLosslessSource = ext == 'FLAC' || ext == 'M4A';
|
||||
if (isLosslessTarget && !isLosslessSource) continue;
|
||||
selectedItems.add(item);
|
||||
}
|
||||
|
||||
if (selectedItems.isEmpty) {
|
||||
@@ -4706,16 +5023,22 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
}
|
||||
|
||||
// Confirm
|
||||
final isLossless = targetFormat == 'ALAC' || targetFormat == 'FLAC';
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(context.l10n.selectionBatchConvertConfirmTitle),
|
||||
content: Text(
|
||||
context.l10n.selectionBatchConvertConfirmMessage(
|
||||
selectedItems.length,
|
||||
targetFormat,
|
||||
bitrate,
|
||||
),
|
||||
isLossless
|
||||
? context.l10n.selectionBatchConvertConfirmMessageLossless(
|
||||
selectedItems.length,
|
||||
targetFormat,
|
||||
)
|
||||
: context.l10n.selectionBatchConvertConfirmMessage(
|
||||
selectedItems.length,
|
||||
targetFormat,
|
||||
bitrate,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
@@ -4736,7 +5059,10 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
final total = selectedItems.length;
|
||||
final historyDb = HistoryDatabase.instance;
|
||||
final newQuality =
|
||||
'${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}';
|
||||
(targetFormat.toUpperCase() == 'ALAC' ||
|
||||
targetFormat.toUpperCase() == 'FLAC')
|
||||
? '${targetFormat.toUpperCase()} Lossless'
|
||||
: '${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}';
|
||||
final settings = ref.read(settingsProvider);
|
||||
final shouldEmbedLyrics =
|
||||
settings.embedLyrics && settings.lyricsMode != 'external';
|
||||
@@ -4756,7 +5082,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
);
|
||||
|
||||
try {
|
||||
// Read metadata from file
|
||||
final metadata = <String, String>{
|
||||
'TITLE': item.trackName,
|
||||
'ARTIST': item.artistName,
|
||||
@@ -4785,7 +5110,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
1000,
|
||||
);
|
||||
|
||||
// Extract cover art
|
||||
String? coverPath;
|
||||
try {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
@@ -4800,7 +5124,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// Handle SAF vs regular file
|
||||
String workingPath = item.filePath;
|
||||
final isSaf = isContentUri(item.filePath);
|
||||
String? safTempPath;
|
||||
@@ -4813,7 +5136,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
workingPath = safTempPath;
|
||||
}
|
||||
|
||||
// Convert
|
||||
final newPath = await FFmpegService.convertAudioFormat(
|
||||
inputPath: workingPath,
|
||||
targetFormat: targetFormat.toLowerCase(),
|
||||
@@ -4823,7 +5145,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
deleteOriginal: !isSaf,
|
||||
);
|
||||
|
||||
// Cleanup cover temp
|
||||
if (coverPath != null) {
|
||||
try {
|
||||
await File(coverPath).delete();
|
||||
@@ -4850,13 +5171,27 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
final baseName = dotIdx > 0
|
||||
? oldFileName.substring(0, dotIdx)
|
||||
: oldFileName;
|
||||
final newExt = targetFormat.toLowerCase() == 'opus'
|
||||
? '.opus'
|
||||
: '.mp3';
|
||||
String newExt;
|
||||
String mimeType;
|
||||
switch (targetFormat.toLowerCase()) {
|
||||
case 'opus':
|
||||
newExt = '.opus';
|
||||
mimeType = 'audio/opus';
|
||||
break;
|
||||
case 'alac':
|
||||
newExt = '.m4a';
|
||||
mimeType = 'audio/mp4';
|
||||
break;
|
||||
case 'flac':
|
||||
newExt = '.flac';
|
||||
mimeType = 'audio/flac';
|
||||
break;
|
||||
default:
|
||||
newExt = '.mp3';
|
||||
mimeType = 'audio/mpeg';
|
||||
break;
|
||||
}
|
||||
final newFileName = '$baseName$newExt';
|
||||
final mimeType = targetFormat.toLowerCase() == 'opus'
|
||||
? 'audio/opus'
|
||||
: 'audio/mpeg';
|
||||
|
||||
final safUri = await PlatformBridge.createSafFileFromPath(
|
||||
treeUri: treeUri,
|
||||
@@ -5040,6 +5375,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
final allSelected =
|
||||
selectedCount == unifiedItems.length && unifiedItems.isNotEmpty;
|
||||
final localOnlySelection = _isLocalOnlySelection(unifiedItems);
|
||||
final flacEligibleCount = _selectedFlacEligibleLocalItems(
|
||||
unifiedItems,
|
||||
).length;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
@@ -5129,6 +5467,18 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
// Action buttons row: Share/Re-enrich, Convert, Delete
|
||||
Row(
|
||||
children: [
|
||||
if (localOnlySelection && flacEligibleCount > 0) ...[
|
||||
Expanded(
|
||||
child: _SelectionActionButton(
|
||||
icon: Icons.download_for_offline_outlined,
|
||||
label:
|
||||
'${context.l10n.queueFlacAction} ($flacEligibleCount)',
|
||||
onPressed: () => _queueSelectedLocalAsFlac(unifiedItems),
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Expanded(
|
||||
child: _SelectionActionButton(
|
||||
icon: localOnlySelection
|
||||
|
||||
@@ -164,7 +164,7 @@ class _RecentDonorsCard extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
const donorNames = <String>['a fan'];
|
||||
const donorNames = <String>['McNuggets Jimmy', 'zcc09', 'micahRichie', 'a fan', 'CJBGR'];
|
||||
|
||||
// Match SettingsGroup color logic
|
||||
final cardColor = isDark
|
||||
@@ -479,8 +479,8 @@ int _cr(String v) {
|
||||
return r;
|
||||
}
|
||||
|
||||
// Highlighted supporters (hashes of names): none for now.
|
||||
const _cv = <int>{};
|
||||
// Highlighted supporters (hashes of names).
|
||||
const _cv = <int>{1211573191, 1003219236, 560908930};
|
||||
|
||||
class _SupporterChip extends StatelessWidget {
|
||||
final String name;
|
||||
|
||||
@@ -300,7 +300,6 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
final topPadding = normalizedHeaderTopPadding(context);
|
||||
|
||||
final isBuiltInService = _builtInServices.contains(settings.defaultService);
|
||||
final isTidalService = settings.defaultService == 'tidal';
|
||||
|
||||
return PopScope(
|
||||
canPop: true,
|
||||
@@ -408,35 +407,8 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
onTap: () => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setAudioQuality('HI_RES_LOSSLESS'),
|
||||
showDivider: isTidalService,
|
||||
showDivider: false,
|
||||
),
|
||||
// Lossy 320kbps option (Tidal only) - downloads M4A, converts to MP3/Opus
|
||||
if (isTidalService)
|
||||
_QualityOption(
|
||||
title: context.l10n.downloadLossy320,
|
||||
subtitle: _getTidalHighFormatLabel(
|
||||
settings.tidalHighFormat,
|
||||
),
|
||||
isSelected: settings.audioQuality == 'HIGH',
|
||||
onTap: () => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setAudioQuality('HIGH'),
|
||||
showDivider: false,
|
||||
),
|
||||
if (isTidalService && settings.audioQuality == 'HIGH')
|
||||
SettingsItem(
|
||||
icon: Icons.tune,
|
||||
title: context.l10n.downloadLossyFormat,
|
||||
subtitle: _getTidalHighFormatLabel(
|
||||
settings.tidalHighFormat,
|
||||
),
|
||||
onTap: () => _showTidalHighFormatPicker(
|
||||
context,
|
||||
ref,
|
||||
settings.tidalHighFormat,
|
||||
),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
if (!isBuiltInService) ...[
|
||||
Padding(
|
||||
@@ -464,12 +436,12 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
],
|
||||
SettingsItem(
|
||||
title: context.l10n.youtubeOpusBitrateTitle,
|
||||
subtitle: '${settings.youtubeOpusBitrate}kbps (128/256)',
|
||||
subtitle: '${settings.youtubeOpusBitrate}kbps (128/256/320)',
|
||||
onTap: () => _showYoutubeBitratePicker(
|
||||
context: context,
|
||||
title: context.l10n.youtubeOpusBitrateTitle,
|
||||
currentValue: settings.youtubeOpusBitrate,
|
||||
options: const [128, 256],
|
||||
options: const [128, 256, 320],
|
||||
onSave: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setYoutubeOpusBitrate(value),
|
||||
@@ -1338,8 +1310,27 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
subtitle: Text(context.l10n.setupChooseFromFilesSubtitle),
|
||||
onTap: () async {
|
||||
Navigator.pop(ctx);
|
||||
if (Platform.isIOS) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 250));
|
||||
}
|
||||
|
||||
// Note: iOS requires folder to have at least one file to be selectable
|
||||
final result = await FilePicker.platform.getDirectoryPath();
|
||||
String? result;
|
||||
try {
|
||||
result = await FilePicker.platform.getDirectoryPath();
|
||||
} catch (e) {
|
||||
if (ctx.mounted) {
|
||||
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to open folder picker: $e'),
|
||||
backgroundColor: Theme.of(ctx).colorScheme.error,
|
||||
duration: const Duration(seconds: 4),
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (result != null) {
|
||||
// iOS: Validate the selected path is writable (not iCloud or container root)
|
||||
if (Platform.isIOS) {
|
||||
@@ -1691,104 +1682,6 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
);
|
||||
}
|
||||
|
||||
String _getTidalHighFormatLabel(String format) {
|
||||
switch (format) {
|
||||
case 'mp3_320':
|
||||
return 'MP3 320kbps';
|
||||
case 'opus_256':
|
||||
return 'Opus 256kbps';
|
||||
case 'opus_128':
|
||||
return 'Opus 128kbps';
|
||||
default:
|
||||
return 'MP3 320kbps';
|
||||
}
|
||||
}
|
||||
|
||||
void _showTidalHighFormatPicker(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
String current,
|
||||
) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
),
|
||||
builder: (context) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||
child: Text(
|
||||
context.l10n.downloadLossy320Format,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||
child: Text(
|
||||
context.l10n.downloadLossy320FormatDesc,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.audiotrack),
|
||||
title: Text(context.l10n.downloadLossyMp3),
|
||||
subtitle: Text(context.l10n.downloadLossyMp3Subtitle),
|
||||
trailing: current == 'mp3_320'
|
||||
? Icon(Icons.check, color: colorScheme.primary)
|
||||
: null,
|
||||
onTap: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setTidalHighFormat('mp3_320');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.graphic_eq),
|
||||
title: Text(context.l10n.downloadLossyOpus256),
|
||||
subtitle: Text(context.l10n.downloadLossyOpus256Subtitle),
|
||||
trailing: current == 'opus_256'
|
||||
? Icon(Icons.check, color: colorScheme.primary)
|
||||
: null,
|
||||
onTap: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setTidalHighFormat('opus_256');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.graphic_eq),
|
||||
title: Text(context.l10n.downloadLossyOpus128),
|
||||
subtitle: Text(context.l10n.downloadLossyOpus128Subtitle),
|
||||
trailing: current == 'opus_128'
|
||||
? Icon(Icons.check, color: colorScheme.primary)
|
||||
: null,
|
||||
onTap: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setTidalHighFormat('opus_128');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showNetworkModePicker(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
|
||||
@@ -241,6 +241,99 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
||||
}
|
||||
}
|
||||
|
||||
String _getAutoScanLabel(BuildContext context, String mode) {
|
||||
switch (mode) {
|
||||
case 'on_open':
|
||||
return context.l10n.libraryAutoScanOnOpen;
|
||||
case 'daily':
|
||||
return context.l10n.libraryAutoScanDaily;
|
||||
case 'weekly':
|
||||
return context.l10n.libraryAutoScanWeekly;
|
||||
default:
|
||||
return context.l10n.libraryAutoScanOff;
|
||||
}
|
||||
}
|
||||
|
||||
void _showAutoScanPicker(BuildContext context, String current) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
),
|
||||
builder: (context) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||
child: Text(
|
||||
context.l10n.libraryAutoScan,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleLarge
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||
child: Text(
|
||||
context.l10n.libraryAutoScanSubtitle,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
_AutoScanOption(
|
||||
icon: Icons.block,
|
||||
title: context.l10n.libraryAutoScanOff,
|
||||
selected: current == 'off',
|
||||
colorScheme: colorScheme,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setLocalLibraryAutoScan('off');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
_AutoScanOption(
|
||||
icon: Icons.open_in_new,
|
||||
title: context.l10n.libraryAutoScanOnOpen,
|
||||
selected: current == 'on_open',
|
||||
colorScheme: colorScheme,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setLocalLibraryAutoScan('on_open');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
_AutoScanOption(
|
||||
icon: Icons.today,
|
||||
title: context.l10n.libraryAutoScanDaily,
|
||||
selected: current == 'daily',
|
||||
colorScheme: colorScheme,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setLocalLibraryAutoScan('daily');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
_AutoScanOption(
|
||||
icon: Icons.date_range,
|
||||
title: context.l10n.libraryAutoScanWeekly,
|
||||
selected: current == 'weekly',
|
||||
colorScheme: colorScheme,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setLocalLibraryAutoScan('weekly');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final settings = ref.watch(settingsProvider);
|
||||
@@ -344,7 +437,18 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
||||
onChanged: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setLocalLibraryShowDuplicates(value),
|
||||
showDivider: false,
|
||||
),
|
||||
Opacity(
|
||||
opacity: settings.localLibraryEnabled ? 1.0 : 0.5,
|
||||
child: SettingsItem(
|
||||
icon: Icons.autorenew_rounded,
|
||||
title: context.l10n.libraryAutoScan,
|
||||
subtitle: _getAutoScanLabel(context, settings.localLibraryAutoScan),
|
||||
onTap: settings.localLibraryEnabled
|
||||
? () => _showAutoScanPicker(context, settings.localLibraryAutoScan)
|
||||
: null,
|
||||
showDivider: false,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -825,3 +929,31 @@ class _ScanProgressTile extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AutoScanOption extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final bool selected;
|
||||
final ColorScheme colorScheme;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _AutoScanOption({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.selected,
|
||||
required this.colorScheme,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
leading: Icon(icon),
|
||||
title: Text(title),
|
||||
trailing: selected
|
||||
? Icon(Icons.check, color: colorScheme.primary)
|
||||
: null,
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,7 +321,26 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
title: Text(context.l10n.setupChooseFromFiles),
|
||||
onTap: () async {
|
||||
Navigator.pop(ctx);
|
||||
final result = await FilePicker.platform.getDirectoryPath();
|
||||
if (Platform.isIOS) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 250));
|
||||
}
|
||||
|
||||
String? result;
|
||||
try {
|
||||
result = await FilePicker.platform.getDirectoryPath();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to open folder picker: $e'),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
duration: const Duration(seconds: 4),
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (result != null) {
|
||||
// iOS: Validate the selected path is writable
|
||||
if (Platform.isIOS) {
|
||||
|
||||
+13
-16
@@ -269,22 +269,19 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: SettingsGroup(
|
||||
children: filteredExtensions.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final ext = entry.value;
|
||||
return _ExtensionItem(
|
||||
extension: ext,
|
||||
showDivider: index < filteredExtensions.length - 1,
|
||||
isDownloading: downloadingId == ext.id,
|
||||
onInstall: () => _installExtension(ext),
|
||||
onUpdate: () => _updateExtension(ext),
|
||||
onTap: () => _showExtensionDetails(ext),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
child: SettingsGroup(
|
||||
children: filteredExtensions.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final ext = entry.value;
|
||||
return _ExtensionItem(
|
||||
extension: ext,
|
||||
showDivider: index < filteredExtensions.length - 1,
|
||||
isDownloading: downloadingId == ext.id,
|
||||
onInstall: () => _installExtension(ext),
|
||||
onUpdate: () => _updateExtension(ext),
|
||||
onTap: () => _showExtensionDetails(ext),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -37,7 +37,6 @@ class CoverCacheManager {
|
||||
final appDir = await getApplicationSupportDirectory();
|
||||
_cachePath = p.join(appDir.path, 'cover_cache');
|
||||
|
||||
// Ensure cache directory exists
|
||||
await Directory(_cachePath!).create(recursive: true);
|
||||
|
||||
debugPrint('CoverCacheManager: Initializing at $_cachePath');
|
||||
@@ -48,7 +47,6 @@ class CoverCacheManager {
|
||||
debugPrint('CoverCacheManager: Initialized successfully');
|
||||
} catch (e) {
|
||||
debugPrint('CoverCacheManager: Failed to initialize: $e');
|
||||
// Will fallback to DefaultCacheManager
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1209,7 +1209,8 @@ class FFmpegService {
|
||||
}
|
||||
|
||||
/// Unified audio format conversion with full metadata + cover preservation.
|
||||
/// Supports: FLAC/MP3/Opus -> MP3/Opus (any direction except same format).
|
||||
/// Supports: FLAC/M4A/MP3/Opus -> MP3/Opus/ALAC/FLAC.
|
||||
/// ALAC and FLAC targets are lossless (bitrate parameter is ignored).
|
||||
/// Returns the new file path on success, null on failure.
|
||||
static Future<String?> convertAudioFormat({
|
||||
required String inputPath,
|
||||
@@ -1220,11 +1221,30 @@ class FFmpegService {
|
||||
bool deleteOriginal = true,
|
||||
}) async {
|
||||
final format = targetFormat.toLowerCase();
|
||||
if (format != 'mp3' && format != 'opus') {
|
||||
if (!const {'mp3', 'opus', 'alac', 'flac'}.contains(format)) {
|
||||
_log.e('Unsupported target format: $targetFormat');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Lossless targets: dedicated single-pass methods
|
||||
if (format == 'alac') {
|
||||
return _convertToAlac(
|
||||
inputPath: inputPath,
|
||||
metadata: metadata,
|
||||
coverPath: coverPath,
|
||||
deleteOriginal: deleteOriginal,
|
||||
);
|
||||
}
|
||||
if (format == 'flac') {
|
||||
return _convertToFlac(
|
||||
inputPath: inputPath,
|
||||
metadata: metadata,
|
||||
coverPath: coverPath,
|
||||
deleteOriginal: deleteOriginal,
|
||||
);
|
||||
}
|
||||
|
||||
// Lossy targets: MP3 / Opus
|
||||
final extension = format == 'opus' ? '.opus' : '.mp3';
|
||||
final outputPath = _buildOutputPath(inputPath, extension);
|
||||
|
||||
@@ -1296,6 +1316,257 @@ class FFmpegService {
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
/// Convert any audio format to ALAC (Apple Lossless) in an M4A container.
|
||||
/// Metadata and cover art are embedded in a single FFmpeg pass.
|
||||
static Future<String?> _convertToAlac({
|
||||
required String inputPath,
|
||||
required Map<String, String> metadata,
|
||||
String? coverPath,
|
||||
bool deleteOriginal = true,
|
||||
}) async {
|
||||
final outputPath = _buildOutputPath(inputPath, '.m4a');
|
||||
|
||||
final cmdBuffer = StringBuffer();
|
||||
cmdBuffer.write('-i "$inputPath" ');
|
||||
|
||||
// Cover art as second input for M4A attached picture
|
||||
final hasCover = coverPath != null &&
|
||||
coverPath.trim().isNotEmpty &&
|
||||
await File(coverPath).exists();
|
||||
if (hasCover) {
|
||||
cmdBuffer.write('-i "$coverPath" ');
|
||||
}
|
||||
|
||||
cmdBuffer.write('-map 0:a ');
|
||||
if (hasCover) {
|
||||
cmdBuffer.write('-map 1:v -c:v copy -disposition:v:0 attached_pic ');
|
||||
}
|
||||
cmdBuffer.write('-c:a alac ');
|
||||
cmdBuffer.write('-map_metadata -1 ');
|
||||
|
||||
// Embed M4A metadata tags
|
||||
final m4aTags = _convertToM4aTags(metadata);
|
||||
for (final entry in m4aTags.entries) {
|
||||
final sanitized = entry.value.replaceAll('"', '\\"');
|
||||
cmdBuffer.write('-metadata ${entry.key}="$sanitized" ');
|
||||
}
|
||||
|
||||
cmdBuffer.write('"$outputPath" -y');
|
||||
|
||||
_log.i(
|
||||
'Converting ${inputPath.split(Platform.pathSeparator).last} to ALAC',
|
||||
);
|
||||
final result = await _execute(cmdBuffer.toString());
|
||||
|
||||
if (!result.success) {
|
||||
_log.e('ALAC conversion failed: ${result.output}');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (deleteOriginal) {
|
||||
try {
|
||||
await File(inputPath).delete();
|
||||
_log.i(
|
||||
'Deleted original: ${inputPath.split(Platform.pathSeparator).last}',
|
||||
);
|
||||
} catch (e) {
|
||||
_log.w('Failed to delete original: $e');
|
||||
}
|
||||
}
|
||||
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
/// Convert any audio format to FLAC with metadata and cover art preservation.
|
||||
static Future<String?> _convertToFlac({
|
||||
required String inputPath,
|
||||
required Map<String, String> metadata,
|
||||
String? coverPath,
|
||||
bool deleteOriginal = true,
|
||||
}) async {
|
||||
final outputPath = _buildOutputPath(inputPath, '.flac');
|
||||
|
||||
final cmdBuffer = StringBuffer();
|
||||
cmdBuffer.write('-i "$inputPath" ');
|
||||
|
||||
final hasCover = coverPath != null &&
|
||||
coverPath.trim().isNotEmpty &&
|
||||
await File(coverPath).exists();
|
||||
if (hasCover) {
|
||||
cmdBuffer.write('-i "$coverPath" ');
|
||||
}
|
||||
|
||||
cmdBuffer.write('-map 0:a ');
|
||||
if (hasCover) {
|
||||
cmdBuffer.write('-map 1:v -c:v copy -disposition:v:0 attached_pic ');
|
||||
cmdBuffer.write('-metadata:s:v title="Album cover" ');
|
||||
cmdBuffer.write('-metadata:s:v comment="Cover (front)" ');
|
||||
}
|
||||
cmdBuffer.write('-c:a flac -compression_level 8 ');
|
||||
cmdBuffer.write('-map_metadata 0 ');
|
||||
|
||||
final vorbisComments = _normalizeToVorbisComments(metadata);
|
||||
for (final entry in vorbisComments.entries) {
|
||||
final sanitized = entry.value.replaceAll('"', '\\"');
|
||||
cmdBuffer.write('-metadata ${entry.key}="$sanitized" ');
|
||||
}
|
||||
|
||||
cmdBuffer.write('"$outputPath" -y');
|
||||
|
||||
_log.i(
|
||||
'Converting ${inputPath.split(Platform.pathSeparator).last} to FLAC',
|
||||
);
|
||||
final result = await _execute(cmdBuffer.toString());
|
||||
|
||||
if (!result.success) {
|
||||
_log.e('FLAC conversion failed: ${result.output}');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (deleteOriginal) {
|
||||
try {
|
||||
await File(inputPath).delete();
|
||||
_log.i(
|
||||
'Deleted original: ${inputPath.split(Platform.pathSeparator).last}',
|
||||
);
|
||||
} catch (e) {
|
||||
_log.w('Failed to delete original: $e');
|
||||
}
|
||||
}
|
||||
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
/// Normalize metadata keys to standard Vorbis comment names, filtering out
|
||||
/// technical fields (bit_depth, sample_rate, duration, etc.).
|
||||
static Map<String, String> _normalizeToVorbisComments(
|
||||
Map<String, String> metadata,
|
||||
) {
|
||||
final vorbis = <String, String>{};
|
||||
|
||||
for (final entry in metadata.entries) {
|
||||
final key = entry.key.toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]'), '');
|
||||
final value = entry.value;
|
||||
if (value.trim().isEmpty) continue;
|
||||
|
||||
switch (key) {
|
||||
case 'TITLE':
|
||||
vorbis['TITLE'] = value;
|
||||
break;
|
||||
case 'ARTIST':
|
||||
vorbis['ARTIST'] = value;
|
||||
break;
|
||||
case 'ALBUM':
|
||||
vorbis['ALBUM'] = value;
|
||||
break;
|
||||
case 'ALBUMARTIST':
|
||||
vorbis['ALBUMARTIST'] = value;
|
||||
break;
|
||||
case 'TRACKNUMBER':
|
||||
case 'TRACKNBR':
|
||||
case 'TRACK':
|
||||
case 'TRCK':
|
||||
if (value != '0') vorbis['TRACKNUMBER'] = value;
|
||||
break;
|
||||
case 'DISCNUMBER':
|
||||
case 'DISC':
|
||||
case 'TPOS':
|
||||
if (value != '0') vorbis['DISCNUMBER'] = value;
|
||||
break;
|
||||
case 'DATE':
|
||||
case 'YEAR':
|
||||
vorbis['DATE'] = value;
|
||||
break;
|
||||
case 'GENRE':
|
||||
vorbis['GENRE'] = value;
|
||||
break;
|
||||
case 'ISRC':
|
||||
vorbis['ISRC'] = value;
|
||||
break;
|
||||
case 'LABEL':
|
||||
case 'ORGANIZATION':
|
||||
vorbis['ORGANIZATION'] = value;
|
||||
break;
|
||||
case 'COPYRIGHT':
|
||||
vorbis['COPYRIGHT'] = value;
|
||||
break;
|
||||
case 'COMPOSER':
|
||||
vorbis['COMPOSER'] = value;
|
||||
break;
|
||||
case 'COMMENT':
|
||||
vorbis['COMMENT'] = value;
|
||||
break;
|
||||
case 'LYRICS':
|
||||
case 'UNSYNCEDLYRICS':
|
||||
vorbis['LYRICS'] = value;
|
||||
vorbis['UNSYNCEDLYRICS'] = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return vorbis;
|
||||
}
|
||||
|
||||
/// Map Vorbis comment keys to M4A/MP4 metadata tag names for FFmpeg.
|
||||
static Map<String, String> _convertToM4aTags(
|
||||
Map<String, String> metadata,
|
||||
) {
|
||||
final m4aMap = <String, String>{};
|
||||
|
||||
for (final entry in metadata.entries) {
|
||||
final key = entry.key.toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]'), '');
|
||||
final value = entry.value;
|
||||
if (value.trim().isEmpty) continue;
|
||||
|
||||
switch (key) {
|
||||
case 'TITLE':
|
||||
m4aMap['title'] = value;
|
||||
break;
|
||||
case 'ARTIST':
|
||||
m4aMap['artist'] = value;
|
||||
break;
|
||||
case 'ALBUM':
|
||||
m4aMap['album'] = value;
|
||||
break;
|
||||
case 'ALBUMARTIST':
|
||||
m4aMap['album_artist'] = value;
|
||||
break;
|
||||
case 'TRACKNUMBER':
|
||||
case 'TRACK':
|
||||
case 'TRCK':
|
||||
m4aMap['track'] = value;
|
||||
break;
|
||||
case 'DISCNUMBER':
|
||||
case 'DISC':
|
||||
case 'TPOS':
|
||||
m4aMap['disc'] = value;
|
||||
break;
|
||||
case 'DATE':
|
||||
case 'YEAR':
|
||||
m4aMap['date'] = value;
|
||||
break;
|
||||
case 'GENRE':
|
||||
m4aMap['genre'] = value;
|
||||
break;
|
||||
case 'COMPOSER':
|
||||
m4aMap['composer'] = value;
|
||||
break;
|
||||
case 'COMMENT':
|
||||
m4aMap['comment'] = value;
|
||||
break;
|
||||
case 'COPYRIGHT':
|
||||
m4aMap['copyright'] = value;
|
||||
break;
|
||||
case 'LYRICS':
|
||||
case 'UNSYNCEDLYRICS':
|
||||
m4aMap['lyrics'] = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return m4aMap;
|
||||
}
|
||||
|
||||
static Map<String, String> _convertToId3Tags(
|
||||
Map<String, String> vorbisMetadata,
|
||||
) {
|
||||
|
||||
@@ -12,7 +12,6 @@ final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
|
||||
/// Cached current iOS container path for path normalization
|
||||
String? _currentContainerPath;
|
||||
|
||||
/// SQLite database service for download history
|
||||
/// Provides O(1) lookups by spotify_id and isrc with proper indexing
|
||||
class HistoryDatabase {
|
||||
static final HistoryDatabase instance = HistoryDatabase._init();
|
||||
@@ -78,7 +77,6 @@ class HistoryDatabase {
|
||||
)
|
||||
''');
|
||||
|
||||
// Indexes for fast lookups
|
||||
await db.execute('CREATE INDEX idx_spotify_id ON history(spotify_id)');
|
||||
await db.execute('CREATE INDEX idx_isrc ON history(isrc)');
|
||||
await db.execute(
|
||||
@@ -171,7 +169,6 @@ class HistoryDatabase {
|
||||
try {
|
||||
final db = await database;
|
||||
|
||||
// Get all items with iOS paths
|
||||
final rows = await db.query('history', columns: ['id', 'file_path']);
|
||||
int updatedCount = 0;
|
||||
final batch = db.batch();
|
||||
@@ -198,7 +195,6 @@ class HistoryDatabase {
|
||||
await batch.commit(noResult: true);
|
||||
}
|
||||
|
||||
// Save current container path
|
||||
await prefs.setString('ios_last_container_path', _currentContainerPath!);
|
||||
|
||||
_log.i('iOS path migration complete: $updatedCount paths updated');
|
||||
@@ -323,7 +319,6 @@ class HistoryDatabase {
|
||||
};
|
||||
}
|
||||
|
||||
/// Insert or update a history item
|
||||
Future<void> upsert(Map<String, dynamic> json) async {
|
||||
final db = await database;
|
||||
await db.insert(
|
||||
@@ -345,7 +340,6 @@ class HistoryDatabase {
|
||||
return rows.map(_dbRowToJson).toList();
|
||||
}
|
||||
|
||||
/// Get item by ID
|
||||
Future<Map<String, dynamic>?> getById(String id) async {
|
||||
final db = await database;
|
||||
final rows = await db.query(
|
||||
@@ -403,26 +397,22 @@ class HistoryDatabase {
|
||||
return rows.map((r) => r['spotify_id'] as String).toSet();
|
||||
}
|
||||
|
||||
/// Delete by ID
|
||||
Future<void> deleteById(String id) async {
|
||||
final db = await database;
|
||||
await db.delete('history', where: 'id = ?', whereArgs: [id]);
|
||||
}
|
||||
|
||||
/// Delete by Spotify ID
|
||||
Future<void> deleteBySpotifyId(String spotifyId) async {
|
||||
final db = await database;
|
||||
await db.delete('history', where: 'spotify_id = ?', whereArgs: [spotifyId]);
|
||||
}
|
||||
|
||||
/// Clear all history
|
||||
Future<void> clearAll() async {
|
||||
final db = await database;
|
||||
await db.delete('history');
|
||||
_log.i('Cleared all history');
|
||||
}
|
||||
|
||||
/// Get total count
|
||||
Future<int> getCount() async {
|
||||
final db = await database;
|
||||
final result = await db.rawQuery('SELECT COUNT(*) as count FROM history');
|
||||
@@ -459,7 +449,6 @@ class HistoryDatabase {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Close database
|
||||
Future<void> close() async {
|
||||
final db = await database;
|
||||
await db.close();
|
||||
|
||||
@@ -123,7 +123,7 @@ class LibraryDatabase {
|
||||
|
||||
return await openDatabase(
|
||||
path,
|
||||
version: 4, // Bumped version for bitrate column
|
||||
version: 4,
|
||||
onConfigure: (db) async {
|
||||
await db.rawQuery('PRAGMA journal_mode = WAL');
|
||||
await db.execute('PRAGMA synchronous = NORMAL');
|
||||
@@ -331,13 +331,11 @@ class LibraryDatabase {
|
||||
String? trackName,
|
||||
String? artistName,
|
||||
}) async {
|
||||
// First try ISRC if available
|
||||
if (isrc != null && isrc.isNotEmpty) {
|
||||
final byIsrc = await getByIsrc(isrc);
|
||||
if (byIsrc != null) return byIsrc;
|
||||
}
|
||||
|
||||
// Then try name matching
|
||||
if (trackName != null && artistName != null) {
|
||||
final matches = await findByTrackAndArtist(trackName, artistName);
|
||||
if (matches.isNotEmpty) return matches.first;
|
||||
@@ -523,7 +521,6 @@ class LibraryDatabase {
|
||||
return rows.map((r) => r['file_path'] as String).toSet();
|
||||
}
|
||||
|
||||
/// Delete multiple items by their file paths
|
||||
Future<int> deleteByPaths(List<String> filePaths) async {
|
||||
if (filePaths.isEmpty) return 0;
|
||||
final db = await database;
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
import 'package:spotiflac_android/models/settings.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/services/library_database.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
|
||||
class LocalTrackRedownloadResolution {
|
||||
final LocalLibraryItem localItem;
|
||||
final Track? match;
|
||||
final int score;
|
||||
final String reason;
|
||||
|
||||
const LocalTrackRedownloadResolution({
|
||||
required this.localItem,
|
||||
required this.match,
|
||||
required this.score,
|
||||
required this.reason,
|
||||
});
|
||||
|
||||
bool get canQueue => match != null;
|
||||
}
|
||||
|
||||
class LocalTrackRedownloadService {
|
||||
static const int _minimumConfidenceScore = 85;
|
||||
static const int _ambiguousScoreGap = 8;
|
||||
|
||||
static bool isFlacUpgradeEligible(LocalLibraryItem item) {
|
||||
final format = item.format?.trim().toLowerCase();
|
||||
if (format == 'flac') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !item.filePath.toLowerCase().endsWith('.flac');
|
||||
}
|
||||
|
||||
static Future<LocalTrackRedownloadResolution> resolveBestMatch(
|
||||
LocalLibraryItem item, {
|
||||
required bool includeExtensions,
|
||||
}) async {
|
||||
final query = _buildSearchQuery(item);
|
||||
final rawResults = await PlatformBridge.searchTracksWithMetadataProviders(
|
||||
query,
|
||||
limit: 10,
|
||||
includeExtensions: includeExtensions,
|
||||
);
|
||||
|
||||
if (rawResults.isEmpty) {
|
||||
return LocalTrackRedownloadResolution(
|
||||
localItem: item,
|
||||
match: null,
|
||||
score: 0,
|
||||
reason: 'No candidates found',
|
||||
);
|
||||
}
|
||||
|
||||
final scored =
|
||||
rawResults
|
||||
.map(
|
||||
(raw) => (
|
||||
track: _parseSearchTrack(raw),
|
||||
score: _scoreMatch(item, raw),
|
||||
),
|
||||
)
|
||||
.where((entry) => entry.track.name.trim().isNotEmpty)
|
||||
.toList(growable: false)
|
||||
..sort((a, b) => b.score.compareTo(a.score));
|
||||
|
||||
if (scored.isEmpty) {
|
||||
return LocalTrackRedownloadResolution(
|
||||
localItem: item,
|
||||
match: null,
|
||||
score: 0,
|
||||
reason: 'No usable candidates found',
|
||||
);
|
||||
}
|
||||
|
||||
final best = scored.first;
|
||||
final runnerUp = scored.length > 1 ? scored[1] : null;
|
||||
final exactIsrc =
|
||||
_normalizedIsrc(item.isrc) != null &&
|
||||
_normalizedIsrc(item.isrc) == _normalizedIsrc(best.track.isrc);
|
||||
final isAmbiguous =
|
||||
!exactIsrc &&
|
||||
runnerUp != null &&
|
||||
best.score < (_minimumConfidenceScore + 10) &&
|
||||
(best.score - runnerUp.score) <= _ambiguousScoreGap;
|
||||
|
||||
if (!exactIsrc && (best.score < _minimumConfidenceScore || isAmbiguous)) {
|
||||
return LocalTrackRedownloadResolution(
|
||||
localItem: item,
|
||||
match: null,
|
||||
score: best.score,
|
||||
reason: isAmbiguous ? 'Ambiguous match' : 'Low-confidence match',
|
||||
);
|
||||
}
|
||||
|
||||
return LocalTrackRedownloadResolution(
|
||||
localItem: item,
|
||||
match: best.track,
|
||||
score: best.score,
|
||||
reason: exactIsrc ? 'Exact ISRC match' : 'High-confidence metadata match',
|
||||
);
|
||||
}
|
||||
|
||||
static String preferredFlacService(AppSettings settings) {
|
||||
switch (settings.defaultService.toLowerCase()) {
|
||||
case 'tidal':
|
||||
case 'qobuz':
|
||||
case 'deezer':
|
||||
return settings.defaultService.toLowerCase();
|
||||
default:
|
||||
return 'tidal';
|
||||
}
|
||||
}
|
||||
|
||||
static String preferredFlacQualityForService(String service) {
|
||||
return service.toLowerCase() == 'deezer' ? 'FLAC' : 'LOSSLESS';
|
||||
}
|
||||
|
||||
static String _buildSearchQuery(LocalLibraryItem item) {
|
||||
final artist = _primaryArtist(item.artistName);
|
||||
final album = item.albumName.trim();
|
||||
if (album.isNotEmpty && album.toLowerCase() != 'unknown album') {
|
||||
return '${item.trackName} $artist $album'.trim();
|
||||
}
|
||||
return '${item.trackName} $artist'.trim();
|
||||
}
|
||||
|
||||
static Track _parseSearchTrack(Map<String, dynamic> data) {
|
||||
final durationMs = _extractDurationMs(data);
|
||||
final itemType = data['item_type']?.toString();
|
||||
|
||||
return Track(
|
||||
id: (data['spotify_id'] ?? data['id'] ?? '').toString(),
|
||||
name: (data['name'] ?? '').toString(),
|
||||
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
|
||||
albumName: (data['album_name'] ?? data['album'] ?? '').toString(),
|
||||
albumArtist: data['album_artist']?.toString(),
|
||||
artistId: (data['artist_id'] ?? data['artistId'])?.toString(),
|
||||
albumId: data['album_id']?.toString(),
|
||||
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
|
||||
isrc: data['isrc']?.toString(),
|
||||
duration: (durationMs / 1000).round(),
|
||||
trackNumber: data['track_number'] as int?,
|
||||
discNumber: data['disc_number'] as int?,
|
||||
releaseDate: data['release_date']?.toString(),
|
||||
totalTracks: data['total_tracks'] as int?,
|
||||
source: data['source']?.toString() ?? data['provider_id']?.toString(),
|
||||
albumType: data['album_type']?.toString(),
|
||||
itemType: itemType,
|
||||
);
|
||||
}
|
||||
|
||||
static int _extractDurationMs(Map<String, dynamic> data) {
|
||||
final durationMsRaw = data['duration_ms'];
|
||||
if (durationMsRaw is num && durationMsRaw > 0) {
|
||||
return durationMsRaw.toInt();
|
||||
}
|
||||
if (durationMsRaw is String) {
|
||||
final parsed = num.tryParse(durationMsRaw.trim());
|
||||
if (parsed != null && parsed > 0) {
|
||||
return parsed.toInt();
|
||||
}
|
||||
}
|
||||
|
||||
final durationSecRaw = data['duration'];
|
||||
if (durationSecRaw is num && durationSecRaw > 0) {
|
||||
return (durationSecRaw * 1000).toInt();
|
||||
}
|
||||
if (durationSecRaw is String) {
|
||||
final parsed = num.tryParse(durationSecRaw.trim());
|
||||
if (parsed != null && parsed > 0) {
|
||||
return (parsed * 1000).toInt();
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int _scoreMatch(LocalLibraryItem item, Map<String, dynamic> raw) {
|
||||
final track = _parseSearchTrack(raw);
|
||||
var score = 0;
|
||||
|
||||
final localIsrc = _normalizedIsrc(item.isrc);
|
||||
final candidateIsrc = _normalizedIsrc(track.isrc);
|
||||
if (localIsrc != null && candidateIsrc != null) {
|
||||
score += localIsrc == candidateIsrc ? 140 : -120;
|
||||
}
|
||||
|
||||
final localTitle = _normalizedTitle(item.trackName);
|
||||
final candidateTitle = _normalizedTitle(track.name);
|
||||
if (localTitle == candidateTitle) {
|
||||
score += 45;
|
||||
} else if (_tokenOverlap(localTitle, candidateTitle) >= 0.75) {
|
||||
score += 24;
|
||||
} else {
|
||||
score -= 25;
|
||||
}
|
||||
|
||||
final localArtist = _normalizedArtistGroup(item.artistName);
|
||||
final candidateArtist = _normalizedArtistGroup(track.artistName);
|
||||
final artistOverlap = _tokenOverlap(localArtist, candidateArtist);
|
||||
if (localArtist == candidateArtist) {
|
||||
score += 30;
|
||||
} else if (artistOverlap >= 0.6) {
|
||||
score += 16;
|
||||
} else {
|
||||
score -= 20;
|
||||
}
|
||||
|
||||
final localAlbum = _normalizedText(item.albumName);
|
||||
final candidateAlbum = _normalizedText(track.albumName);
|
||||
if (localAlbum.isNotEmpty && candidateAlbum.isNotEmpty) {
|
||||
if (localAlbum == candidateAlbum) {
|
||||
score += 12;
|
||||
} else if (_tokenOverlap(localAlbum, candidateAlbum) >= 0.7) {
|
||||
score += 6;
|
||||
}
|
||||
}
|
||||
|
||||
final localDuration = item.duration ?? 0;
|
||||
final candidateDuration = track.duration;
|
||||
if (localDuration > 0 && candidateDuration > 0) {
|
||||
final diff = (localDuration - candidateDuration).abs();
|
||||
if (diff <= 2) {
|
||||
score += 20;
|
||||
} else if (diff <= 5) {
|
||||
score += 12;
|
||||
} else if (diff <= 10) {
|
||||
score += 5;
|
||||
} else if (diff > 20) {
|
||||
score -= 30;
|
||||
}
|
||||
}
|
||||
|
||||
if (item.trackNumber != null &&
|
||||
track.trackNumber != null &&
|
||||
item.trackNumber == track.trackNumber) {
|
||||
score += 6;
|
||||
}
|
||||
if (item.discNumber != null &&
|
||||
track.discNumber != null &&
|
||||
item.discNumber == track.discNumber) {
|
||||
score += 4;
|
||||
}
|
||||
|
||||
final localYear = _extractYear(item.releaseDate);
|
||||
final candidateYear = _extractYear(track.releaseDate);
|
||||
if (localYear != null &&
|
||||
candidateYear != null &&
|
||||
localYear == candidateYear) {
|
||||
score += 4;
|
||||
}
|
||||
|
||||
score += _versionPenalty(item.trackName, track.name);
|
||||
return score;
|
||||
}
|
||||
|
||||
static String? _normalizedIsrc(String? value) {
|
||||
final normalized = value?.trim().toUpperCase();
|
||||
if (normalized == null || normalized.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
static String _normalizedTitle(String value) {
|
||||
final cleaned = _normalizedText(value)
|
||||
.replaceAll(RegExp(r'\b(feat|ft|featuring)\b.*$'), ' ')
|
||||
.replaceAll(RegExp(r'\b(remaster(?:ed)?|deluxe|bonus)\b'), ' ')
|
||||
.replaceAll(RegExp(r'\s+'), ' ')
|
||||
.trim();
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
static String _normalizedArtistGroup(String value) {
|
||||
return _normalizedText(
|
||||
value
|
||||
.replaceAll(RegExp(r'\b(feat|ft|featuring|with|x)\b'), ',')
|
||||
.replaceAll('&', ','),
|
||||
);
|
||||
}
|
||||
|
||||
static String _primaryArtist(String value) {
|
||||
final parts = _normalizedArtistGroup(
|
||||
value,
|
||||
).split(',').map((part) => part.trim()).where((part) => part.isNotEmpty);
|
||||
return parts.isEmpty ? value.trim() : parts.first;
|
||||
}
|
||||
|
||||
static String _normalizedText(String value) {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replaceAll(RegExp(r'[\(\)\[\]\{\}]'), ' ')
|
||||
.replaceAll(RegExp(r'[^a-z0-9, ]+'), ' ')
|
||||
.replaceAll(RegExp(r'\s+'), ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
static double _tokenOverlap(String left, String right) {
|
||||
final leftTokens = left
|
||||
.split(RegExp(r'[\s,]+'))
|
||||
.where((token) => token.isNotEmpty)
|
||||
.toSet();
|
||||
final rightTokens = right
|
||||
.split(RegExp(r'[\s,]+'))
|
||||
.where((token) => token.isNotEmpty)
|
||||
.toSet();
|
||||
if (leftTokens.isEmpty || rightTokens.isEmpty) {
|
||||
return 0;
|
||||
}
|
||||
final intersection = leftTokens.intersection(rightTokens).length;
|
||||
final denominator = leftTokens.length > rightTokens.length
|
||||
? leftTokens.length
|
||||
: rightTokens.length;
|
||||
return intersection / denominator;
|
||||
}
|
||||
|
||||
static int _versionPenalty(String localTitle, String candidateTitle) {
|
||||
const riskyMarkers = [
|
||||
'live',
|
||||
'karaoke',
|
||||
'instrumental',
|
||||
'acoustic',
|
||||
'radio edit',
|
||||
'sped up',
|
||||
'slowed',
|
||||
];
|
||||
final local = _normalizedText(localTitle);
|
||||
final candidate = _normalizedText(candidateTitle);
|
||||
var penalty = 0;
|
||||
for (final marker in riskyMarkers) {
|
||||
final localHas = local.contains(marker);
|
||||
final candidateHas = candidate.contains(marker);
|
||||
if (!localHas && candidateHas) {
|
||||
penalty -= 18;
|
||||
}
|
||||
}
|
||||
return penalty;
|
||||
}
|
||||
|
||||
static int? _extractYear(String? date) {
|
||||
if (date == null || date.length < 4) {
|
||||
return null;
|
||||
}
|
||||
return int.tryParse(date.substring(0, 4));
|
||||
}
|
||||
}
|
||||
+12
-20
@@ -35,7 +35,6 @@ class AppTheme {
|
||||
);
|
||||
}
|
||||
|
||||
/// Create dark theme
|
||||
static ThemeData dark({
|
||||
ColorScheme? dynamicScheme,
|
||||
Color? seedColor,
|
||||
@@ -88,12 +87,11 @@ class AppTheme {
|
||||
),
|
||||
);
|
||||
|
||||
/// Card theme
|
||||
static CardThemeData _cardTheme(ColorScheme scheme) => CardThemeData(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
), // 12 -> 16
|
||||
),
|
||||
color: scheme.surfaceContainerLow,
|
||||
surfaceTintColor: scheme.surfaceTint,
|
||||
);
|
||||
@@ -104,18 +102,17 @@ class AppTheme {
|
||||
elevation: 1,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
), // 20 -> 16
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
),
|
||||
);
|
||||
|
||||
/// Filled button theme
|
||||
static FilledButtonThemeData _filledButtonTheme(ColorScheme scheme) =>
|
||||
FilledButtonThemeData(
|
||||
style: FilledButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
), // 20 -> 16
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
),
|
||||
);
|
||||
@@ -125,18 +122,17 @@ class AppTheme {
|
||||
style: OutlinedButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
), // 20 -> 16
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
),
|
||||
);
|
||||
|
||||
/// Text button theme
|
||||
static TextButtonThemeData _textButtonTheme(ColorScheme scheme) =>
|
||||
TextButtonThemeData(
|
||||
style: TextButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
), // 20 -> 16
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
),
|
||||
);
|
||||
@@ -149,40 +145,39 @@ class AppTheme {
|
||||
foregroundColor: scheme.onPrimaryContainer,
|
||||
);
|
||||
|
||||
/// Input decoration theme
|
||||
static InputDecorationTheme _inputDecorationTheme(ColorScheme scheme) =>
|
||||
InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: scheme.surfaceContainerHighest.withValues(
|
||||
alpha: 0.3,
|
||||
), // Added transparency
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16), // 12 -> 16
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16), // 12 -> 16
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16), // 12 -> 16
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide(color: scheme.primary, width: 2),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16), // 12 -> 16
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide(color: scheme.error, width: 1),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 16,
|
||||
), // consistent padding
|
||||
),
|
||||
);
|
||||
|
||||
static ListTileThemeData _listTileTheme(ColorScheme scheme) =>
|
||||
ListTileThemeData(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
), // 12 -> 16
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
);
|
||||
|
||||
@@ -193,7 +188,6 @@ class AppTheme {
|
||||
surfaceTintColor: scheme.surfaceTint,
|
||||
);
|
||||
|
||||
/// Navigation bar theme
|
||||
static NavigationBarThemeData _navigationBarTheme(
|
||||
ColorScheme scheme, {
|
||||
bool isAmoled = false,
|
||||
@@ -213,7 +207,6 @@ class AppTheme {
|
||||
contentTextStyle: TextStyle(color: scheme.onInverseSurface),
|
||||
);
|
||||
|
||||
/// Progress indicator theme
|
||||
static ProgressIndicatorThemeData _progressIndicatorTheme(
|
||||
ColorScheme scheme,
|
||||
) => ProgressIndicatorThemeData(
|
||||
@@ -243,7 +236,6 @@ class AppTheme {
|
||||
}),
|
||||
);
|
||||
|
||||
/// Chip theme
|
||||
static ChipThemeData _chipTheme(ColorScheme scheme) => ChipThemeData(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
backgroundColor: scheme.surfaceContainerLow,
|
||||
|
||||
@@ -4,7 +4,6 @@ import 'package:dynamic_color/dynamic_color.dart';
|
||||
import 'package:spotiflac_android/providers/theme_provider.dart';
|
||||
import 'package:spotiflac_android/theme/app_theme.dart';
|
||||
|
||||
/// Wrapper widget that provides dynamic color support from device wallpaper
|
||||
class DynamicColorWrapper extends ConsumerWidget {
|
||||
final Widget Function(ThemeData light, ThemeData dark, ThemeMode mode) builder;
|
||||
|
||||
@@ -23,7 +22,6 @@ class DynamicColorWrapper extends ConsumerWidget {
|
||||
ColorScheme darkScheme;
|
||||
|
||||
if (themeSettings.useDynamicColor && lightDynamic != null && darkDynamic != null) {
|
||||
// Use dynamic colors from wallpaper (Android 12+)
|
||||
lightScheme = lightDynamic;
|
||||
darkScheme = darkDynamic;
|
||||
} else {
|
||||
@@ -38,7 +36,6 @@ class DynamicColorWrapper extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
// Apply AMOLED mode if enabled (pure black background)
|
||||
if (themeSettings.useAmoled) {
|
||||
darkScheme = _applyAmoledColors(darkScheme);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,22 @@ final _iosLegacyRelativeDocumentsPattern = RegExp(
|
||||
r'^Data/Application/[A-F0-9\-]+/Documents(?:/(.*))?$',
|
||||
caseSensitive: false,
|
||||
);
|
||||
final _iosNestedLegacyDocumentsPattern = RegExp(
|
||||
r'/Documents/Data/Application/[A-F0-9\-]+/Documents(?:/(.*))?$',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
String _normalizeRecoveredIosSuffix(String suffix) {
|
||||
final trimmed = suffix.trim();
|
||||
if (trimmed.isEmpty) return '';
|
||||
return trimmed.startsWith('/') ? trimmed.substring(1) : trimmed;
|
||||
}
|
||||
|
||||
String _joinRecoveredIosPath(String documentsPath, String suffix) {
|
||||
final normalizedSuffix = _normalizeRecoveredIosSuffix(suffix);
|
||||
if (normalizedSuffix.isEmpty) return documentsPath;
|
||||
return '$documentsPath/$normalizedSuffix';
|
||||
}
|
||||
|
||||
/// Checks if a path is a valid writable directory on iOS.
|
||||
/// Returns false if:
|
||||
@@ -43,6 +59,12 @@ bool isValidIosWritablePath(String path) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Reject stale paths where an old sandbox container path has been embedded
|
||||
// inside the current Documents directory.
|
||||
if (_iosNestedLegacyDocumentsPattern.hasMatch(path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ensure path contains a valid subdirectory (Documents, tmp, Library, etc.)
|
||||
// This handles cases where FilePicker returns container root
|
||||
final containerPattern = RegExp(
|
||||
@@ -70,11 +92,19 @@ Future<String> validateOrFixIosPath(
|
||||
if (!Platform.isIOS) return path;
|
||||
|
||||
final trimmed = path.trim();
|
||||
final docDir = await getApplicationDocumentsDirectory();
|
||||
|
||||
final nestedLegacyMatch = _iosNestedLegacyDocumentsPattern.firstMatch(
|
||||
trimmed,
|
||||
);
|
||||
if (nestedLegacyMatch != null) {
|
||||
return _joinRecoveredIosPath(docDir.path, nestedLegacyMatch.group(1) ?? '');
|
||||
}
|
||||
|
||||
if (isValidIosWritablePath(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
final docDir = await getApplicationDocumentsDirectory();
|
||||
final candidates = <String>[];
|
||||
|
||||
if (trimmed.isNotEmpty) {
|
||||
@@ -92,14 +122,8 @@ Future<String> validateOrFixIosPath(
|
||||
trimmed,
|
||||
);
|
||||
if (legacyRelativeMatch != null) {
|
||||
final suffix = (legacyRelativeMatch.group(1) ?? '').trim();
|
||||
final normalizedSuffix = suffix.startsWith('/')
|
||||
? suffix.substring(1)
|
||||
: suffix;
|
||||
candidates.add(
|
||||
normalizedSuffix.isEmpty
|
||||
? docDir.path
|
||||
: '${docDir.path}/$normalizedSuffix',
|
||||
_joinRecoveredIosPath(docDir.path, legacyRelativeMatch.group(1) ?? ''),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -109,7 +133,7 @@ Future<String> validateOrFixIosPath(
|
||||
final index = trimmed.indexOf(documentsMarker);
|
||||
if (index >= 0) {
|
||||
final suffix = trimmed.substring(index + documentsMarker.length).trim();
|
||||
candidates.add(suffix.isEmpty ? docDir.path : '${docDir.path}/$suffix');
|
||||
candidates.add(_joinRecoveredIosPath(docDir.path, suffix));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,6 +205,14 @@ IosPathValidationResult validateIosPath(String path) {
|
||||
);
|
||||
}
|
||||
|
||||
if (_iosNestedLegacyDocumentsPattern.hasMatch(path)) {
|
||||
return const IosPathValidationResult(
|
||||
isValid: false,
|
||||
errorReason:
|
||||
'Invalid iOS app folder path. Please choose App Documents or another local folder.',
|
||||
);
|
||||
}
|
||||
|
||||
// Check for container root without subdirectory
|
||||
final containerPattern = RegExp(
|
||||
r'/var/mobile/Containers/Data/Application/[A-F0-9\-]+',
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
const localLibraryLastScannedAtKey = 'local_library_last_scanned_at';
|
||||
|
||||
DateTime? readLocalLibraryLastScannedAt(SharedPreferences prefs) {
|
||||
final lastScannedAtStr = prefs.getString(localLibraryLastScannedAtKey);
|
||||
if (lastScannedAtStr != null && lastScannedAtStr.isNotEmpty) {
|
||||
return DateTime.tryParse(lastScannedAtStr);
|
||||
}
|
||||
|
||||
// Backward compatibility for older builds that may have stored epoch millis.
|
||||
final lastScannedAtMs = prefs.getInt(localLibraryLastScannedAtKey);
|
||||
if (lastScannedAtMs != null) {
|
||||
return DateTime.fromMillisecondsSinceEpoch(lastScannedAtMs);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> writeLocalLibraryLastScannedAt(
|
||||
SharedPreferences prefs,
|
||||
DateTime value,
|
||||
) {
|
||||
return prefs.setString(localLibraryLastScannedAtKey, value.toIso8601String());
|
||||
}
|
||||
|
||||
Future<void> clearLocalLibraryLastScannedAt(SharedPreferences prefs) {
|
||||
return prefs.remove(localLibraryLastScannedAtKey);
|
||||
}
|
||||
@@ -8,6 +8,33 @@ const _androidStoragePathAliases = <String>[
|
||||
'/mnt/sdcard',
|
||||
];
|
||||
|
||||
/// Audio file extensions that the app commonly produces or converts between.
|
||||
/// Used to generate extension-stripped match keys so that a file converted from
|
||||
/// one format to another (e.g. .flac → .opus) is still recognised as the same
|
||||
/// track.
|
||||
const _audioExtensions = <String>[
|
||||
'.flac',
|
||||
'.m4a',
|
||||
'.mp3',
|
||||
'.opus',
|
||||
'.ogg',
|
||||
'.wav',
|
||||
'.aac',
|
||||
];
|
||||
|
||||
/// Strips a trailing audio extension from [path] if present.
|
||||
/// Returns the path without extension, or `null` if no known audio extension
|
||||
/// was found.
|
||||
String? _stripAudioExtension(String path) {
|
||||
final lower = path.toLowerCase();
|
||||
for (final ext in _audioExtensions) {
|
||||
if (lower.endsWith(ext)) {
|
||||
return path.substring(0, path.length - ext.length);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Set<String> buildPathMatchKeys(String? filePath) {
|
||||
final raw = filePath?.trim() ?? '';
|
||||
if (raw.isEmpty) return const {};
|
||||
@@ -79,6 +106,18 @@ Set<String> buildPathMatchKeys(String? filePath) {
|
||||
}
|
||||
|
||||
addNormalized(cleaned);
|
||||
|
||||
// Add extension-stripped variants so that a file converted from one audio
|
||||
// format to another (e.g. Song.flac → Song.opus) still matches.
|
||||
final extensionStrippedKeys = <String>{};
|
||||
for (final key in keys) {
|
||||
final stripped = _stripAudioExtension(key);
|
||||
if (stripped != null && stripped.isNotEmpty) {
|
||||
extensionStrippedKeys.add(stripped);
|
||||
}
|
||||
}
|
||||
keys.addAll(extensionStrippedKeys);
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,8 +22,7 @@ class BuiltInService {
|
||||
});
|
||||
}
|
||||
|
||||
/// Default quality options for built-in services
|
||||
/// Note: Tidal lossy (HIGH) removed - use YouTube for lossy downloads
|
||||
/// Default quality options for each built-in service
|
||||
const _builtInServices = [
|
||||
BuiltInService(
|
||||
id: 'tidal',
|
||||
@@ -83,9 +82,9 @@ const _builtInServices = [
|
||||
label: 'YouTube',
|
||||
qualityOptions: [
|
||||
QualityOption(
|
||||
id: 'opus_256',
|
||||
label: 'Opus 256kbps',
|
||||
description: 'Best quality lossy (~8MB per track)',
|
||||
id: 'opus_320',
|
||||
label: 'Opus 320kbps',
|
||||
description: 'Best quality lossy (~10MB per track)',
|
||||
),
|
||||
QualityOption(
|
||||
id: 'mp3_320',
|
||||
@@ -98,7 +97,6 @@ const _builtInServices = [
|
||||
),
|
||||
];
|
||||
|
||||
/// A reusable widget for selecting download service (built-in + extensions)
|
||||
class DownloadServicePicker extends ConsumerStatefulWidget {
|
||||
final String? trackName;
|
||||
final String? artistName;
|
||||
@@ -146,7 +144,7 @@ class DownloadServicePicker extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
||||
static const List<int> _youtubeOpusSupportedBitrates = [128, 256];
|
||||
static const List<int> _youtubeOpusSupportedBitrates = [128, 256, 320];
|
||||
static const List<int> _youtubeMp3SupportedBitrates = [128, 256, 320];
|
||||
|
||||
late String _selectedService;
|
||||
|
||||
@@ -20,6 +20,7 @@ Future<void> showAddTracksToPlaylistSheet(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
List<Track> tracks,
|
||||
{String? playlistNamePrefill}
|
||||
) async {
|
||||
if (tracks.isEmpty) return;
|
||||
|
||||
@@ -31,15 +32,16 @@ Future<void> showAddTracksToPlaylistSheet(
|
||||
showDragHandle: true,
|
||||
isScrollControlled: true,
|
||||
builder: (sheetContext) {
|
||||
return _PlaylistPickerSheetContent(tracks: tracks);
|
||||
return _PlaylistPickerSheetContent(tracks: tracks, playlistNamePrefill: playlistNamePrefill);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class _PlaylistPickerSheetContent extends ConsumerStatefulWidget {
|
||||
final List<Track> tracks;
|
||||
final String? playlistNamePrefill;
|
||||
|
||||
const _PlaylistPickerSheetContent({required this.tracks});
|
||||
const _PlaylistPickerSheetContent({required this.tracks, this.playlistNamePrefill});
|
||||
|
||||
@override
|
||||
ConsumerState<_PlaylistPickerSheetContent> createState() =>
|
||||
@@ -130,7 +132,7 @@ class _PlaylistPickerSheetContentState
|
||||
leading: const Icon(Icons.add_circle_outline),
|
||||
title: Text(context.l10n.collectionCreatePlaylist),
|
||||
onTap: () async {
|
||||
final name = await _promptPlaylistName(context);
|
||||
final name = await _promptPlaylistName(context, widget.playlistNamePrefill);
|
||||
if (name == null || name.trim().isEmpty || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
@@ -221,8 +223,8 @@ class _PlaylistPickerSheetContentState
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> _promptPlaylistName(BuildContext context) async {
|
||||
final controller = TextEditingController();
|
||||
Future<String?> _promptPlaylistName(BuildContext context, String? playlistNamePrefill) async {
|
||||
final controller = TextEditingController(text: playlistNamePrefill);
|
||||
final formKey = GlobalKey<FormState>();
|
||||
|
||||
final result = await showDialog<String>(
|
||||
|
||||
+12
-20
@@ -133,10 +133,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: characters
|
||||
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
version: "1.4.1"
|
||||
checked_yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -557,14 +557,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.5"
|
||||
js:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: js
|
||||
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.2"
|
||||
json_annotation:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -633,18 +625,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.17"
|
||||
version: "0.12.19"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.11.1"
|
||||
version: "0.13.0"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1166,26 +1158,26 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test
|
||||
sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7"
|
||||
sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.26.3"
|
||||
version: "1.30.0"
|
||||
test_api:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.7"
|
||||
version: "0.7.10"
|
||||
test_core:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_core
|
||||
sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0"
|
||||
sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.12"
|
||||
version: "0.6.16"
|
||||
timezone:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: spotiflac_android
|
||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Deezer
|
||||
publish_to: "none"
|
||||
version: 3.8.0+106
|
||||
version: 3.8.8+114
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.0
|
||||
|
||||
Reference in New Issue
Block a user