Merge remote-tracking branch 'origin/dev' into l10n_dev

# Conflicts:
#	lib/l10n/arb/app_es-ES.arb
#	lib/l10n/arb/app_id.arb
#	lib/l10n/arb/app_pt-PT.arb
#	lib/l10n/arb/app_tr-TR.arb
This commit is contained in:
zarzet
2026-03-25 16:08:10 +07:00
132 changed files with 27900 additions and 6496 deletions
+3
View File
@@ -0,0 +1,3 @@
{
"flutter": "3.41.4"
}
+111 -64
View File
@@ -309,32 +309,22 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0 # Full history needed for git-cliff
- name: Extract changelog for version
- name: Generate changelog with git-cliff
id: changelog
uses: orhun/git-cliff-action@v4
with:
config: cliff.toml
args: --latest --strip header
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OUTPUT: /tmp/changelog.txt
- name: Show generated changelog
run: |
VERSION=${{ needs.get-version.outputs.version }}
VERSION_NUM=${VERSION#v} # Remove 'v' prefix
echo "Looking for version: $VERSION_NUM"
# Extract changelog section for this version using sed
# Find the line with version, then print until next version header or end
CHANGELOG=$(sed -n "/^## \[$VERSION_NUM\]/,/^## \[/{ /^## \[$VERSION_NUM\]/d; /^## \[/d; p; }" CHANGELOG.md)
# If no changelog found, use default message
if [ -z "$CHANGELOG" ]; then
echo "No changelog found for version $VERSION_NUM"
CHANGELOG="See CHANGELOG.md for details."
else
echo "Found changelog content"
# Remove trailing --- separator if present (CHANGELOG uses --- between versions)
CHANGELOG=$(echo "$CHANGELOG" | sed '/^---$/d')
fi
# Save to file for multiline support
echo "$CHANGELOG" > /tmp/changelog.txt
echo "Extracted changelog:"
echo "Generated changelog:"
cat /tmp/changelog.txt
- name: Download Android APK
@@ -352,15 +342,22 @@ jobs:
- name: Prepare release body
run: |
VERSION=${{ needs.get-version.outputs.version }}
cat > /tmp/release_body.txt << 'HEADER'
### What's New
HEADER
cat /tmp/changelog.txt >> /tmp/release_body.txt
REPO_OWNER="${{ github.repository_owner }}"
REPO_NAME="${{ github.event.repository.name }}"
CURRENT_REF=$(git rev-list -n 1 "$VERSION" 2>/dev/null || git rev-parse HEAD)
PREVIOUS_TAG=$(git describe --tags --abbrev=0 "${CURRENT_REF}^" 2>/dev/null || true)
# Start with git-cliff changelog, but replace its compare footer with a
# deterministic previous-tag lookup from git.
sed '/^## [0-9][0-9.[:alpha:]-]*$/d; /^\*\*Full Changelog\*\*/d' /tmp/changelog.txt > /tmp/release_body.txt
if [ -n "$PREVIOUS_TAG" ]; then
printf '\n**Full Changelog**: [%s...%s](https://github.com/%s/%s/compare/%s...%s)\n' \
"$PREVIOUS_TAG" "$VERSION" "$REPO_OWNER" "$REPO_NAME" "$PREVIOUS_TAG" "$VERSION" \
>> /tmp/release_body.txt
fi
# Append download section
cat >> /tmp/release_body.txt << FOOTER
---
@@ -396,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]
@@ -404,6 +458,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Download Android APK
uses: actions/download-artifact@v7
@@ -417,52 +473,43 @@ jobs:
name: ios-ipa
path: ./release
- name: Extract changelog for version
- name: Generate changelog with git-cliff for Telegram
uses: orhun/git-cliff-action@v4
with:
config: cliff.toml
args: --latest --strip all
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OUTPUT: /tmp/cliff_tg.txt
- name: Convert changelog for Telegram
id: changelog
run: |
VERSION=${{ needs.get-version.outputs.version }}
VERSION_NUM=${VERSION#v}
# Extract changelog, limit to ~2500 chars for Telegram (4096 limit minus message overhead)
# Use tr -d '\r' to handle CRLF line endings from Windows
FULL_CHANGELOG=$(cat CHANGELOG.md | tr -d '\r' | sed -n "/^## \[$VERSION_NUM\]/,/^## \[/{ /^## \[$VERSION_NUM\]/d; /^## \[/d; p; }" | sed '/^---$/d')
echo "DEBUG: Extracted changelog length: ${#FULL_CHANGELOG}"
echo "DEBUG: First 200 chars: ${FULL_CHANGELOG:0:200}"
if [ -z "$FULL_CHANGELOG" ]; then
CHANGELOG="See release notes on GitHub for details."
if [ ! -s /tmp/cliff_tg.txt ]; then
echo "See release notes on GitHub for details." > /tmp/changelog.txt
else
# Convert GitHub Markdown to Telegram HTML:
# - **text** → <b>text</b>
# - `code` → <code>code</code>
# - ### Header → <b>Header</b>
# - Escape HTML special chars first
# - Remove > blockquote prefix
CHANGELOG=$(echo "$FULL_CHANGELOG" | \
sed 's/^> //' | \
# Convert Markdown to Telegram HTML
CHANGELOG=$(cat /tmp/cliff_tg.txt | \
sed '/^## [0-9][0-9.[:alpha:]-]*$/d' | \
sed '/^\*\*Full Changelog\*\*/d' | \
sed 's/ by \[@[^]]*\](https:\/\/github\.com\/[^)]*)//g' | \
sed 's/ by @[A-Za-z0-9_-]\+//g' | \
sed 's/\[#\([0-9]*\)\]([^)]*)/#\1/g' | \
sed 's/\[@\([^]]*\)\]([^)]*)/@\1/g' | \
sed 's/&/\&amp;/g' | \
sed 's/</\&lt;/g' | \
sed 's/>/\&gt;/g' | \
sed 's/`\([^`]*\)`/<code>\1<\/code>/g' | \
sed 's/\*\*\([^*]*\)\*\*/<b>\1<\/b>/g' | \
sed 's/^### \(.*\)$/<b>\1<\/b>/g' | \
sed 's/^## \(.*\)$/<b>\1<\/b>/g' | \
sed 's/^- /• /g' | \
sed 's/^ - / ◦ /g')
# Take first 2500 characters, then cut at last complete line
sed 's/^- /• /g')
# Truncate for Telegram 4096 char limit
CHANGELOG=$(echo "$CHANGELOG" | head -c 2500 | sed '$d')
# Check if truncated
FULL_LEN=${#FULL_CHANGELOG}
if [ $FULL_LEN -gt 2500 ]; then
CHANGELOG="${CHANGELOG}"$'\n\n... (see full changelog on GitHub)'
fi
echo "$CHANGELOG" > /tmp/changelog.txt
fi
echo "$CHANGELOG" > /tmp/changelog.txt
echo "DEBUG: Final changelog:"
echo "Telegram changelog:"
cat /tmp/changelog.txt
- name: Send to Telegram Channel
+7
View File
@@ -12,6 +12,9 @@ Thumbs.db
# Kiro specs (development only)
.kiro/
# Design assets (banners, mockups)
design/
# Reference folder (development only)
referensi/
@@ -64,6 +67,7 @@ AGENTS.md
# Temp/misc
nul
network_requests.txt
# Log files
*.log
@@ -73,3 +77,6 @@ flutter_*.log
# Development tools
tool/
.claude/settings.local.json
# FVM Version Cache
.fvm/
+2 -2
View File
@@ -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
View File
@@ -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
```
+48 -22
View File
@@ -1,20 +1,19 @@
[![GitHub All Releases](https://img.shields.io/github/downloads/zarzet/SpotiFLAC-Mobile/total?style=for-the-badge&refresh=1)](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
[![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/0a2bd2a033551983fc9fcd83f82fd912c83914fd1094cd8d1c7c6a68eb23233f)
[![Crowdin](https://img.shields.io/badge/HELP%20TRANSLATE%20ON-CROWDIN-%2321252b?style=for-the-badge&logo=crowdin)](https://crowdin.com/project/spotiflac-mobile)
<div align="center">
<img src="icon.png" width="128" />
<picture>
<source media="(prefers-color-scheme: dark)" srcset="assets/images/banner-readme-dark.png">
<source media="(prefers-color-scheme: light)" srcset="assets/images/banner-readme-light.png">
<img alt="SpotiFLAC Mobile" src="assets/images/banner-readme-light.png" width="650" height="auto">
</picture>
Download music in true lossless FLAC from Tidal, Qobuz & Deezer — no account required.
![Android](https://img.shields.io/badge/Android-7.0%2B-3DDC84?style=for-the-badge&logo=android&logoColor=white)
![iOS](https://img.shields.io/badge/iOS-14.0%2B-000000?style=for-the-badge&logo=apple&logoColor=white)
<p align="center">
<a href="https://trendshift.io/repositories/17247">
<img src="https://trendshift.io/api/badge/repositories/17247" alt="zarzet%2FSpotiFLAC-Mobile | Trendshift" width="250" height="55">
</a>
</p>
</div>
### [Download](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
## Screenshots
<p align="center">
@@ -24,32 +23,42 @@ Download music in true lossless FLAC from Tidal, Qobuz & Deezer — no account r
<img src="assets/images/4.jpg?v=2" width="200" />
</p>
<div align="center">
[![GitHub Release](https://img.shields.io/github/v/release/zarzet/SpotiFLAC-Mobile?style=for-the-badge&logo=github)](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
[![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/63a445a956fa71ea347ad3695a62d543e14e341933326b9dbb9a15d79614ef58)
[![Crowdin](https://img.shields.io/badge/HELP%20TRANSLATE%20ON-CROWDIN-%2321252b?style=for-the-badge&logo=crowdin)](https://crowdin.com/project/spotiflac-mobile)
[![Telegram Channel](https://img.shields.io/badge/CHANNEL-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac)
[![Telegram Community](https://img.shields.io/badge/COMMUNITY-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac_chat)
</div>
## Extensions
Extensions allow the community to add new music sources and features without waiting for app updates. When a streaming service API changes or a new source becomes available, extensions can be updated independently.
### 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://zarz.moe/docs) for complete documentation.
Want to create your own extension? Check out the [Extension Development Guide](https://zarzet.github.io/SpotiFLAC-Mobile/docs) for complete documentation.
## Other project
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
## Telegram
[![Telegram Channel](https://img.shields.io/badge/CHANNEL-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac)
[![Telegram Community](https://img.shields.io/badge/COMMUNITY-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac_chat)
## 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.
@@ -68,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?
@@ -75,6 +89,18 @@ _If this software is useful and brings you value, consider supporting the projec
[![Ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](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)
@@ -82,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
@@ -115,10 +115,8 @@ class DownloadService : Service() {
* We must call stopSelf() within a few seconds to avoid a crash.
*/
override fun onTimeout(startId: Int, fgsType: Int) {
// Log the timeout for debugging
android.util.Log.w("DownloadService", "Foreground service timeout reached (6 hours limit). Stopping service.")
// Gracefully stop the service
stopForegroundService()
}
File diff suppressed because it is too large Load Diff
+18
View File
@@ -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
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

+103
View File
@@ -0,0 +1,103 @@
# git-cliff configuration for SpotiFLAC Mobile
# https://git-cliff.org/docs/configuration
[changelog]
# Template for the changelog body
body = """
{%- macro remote_url() -%}
https://github.com/zarzet/SpotiFLAC-Mobile
{%- endmacro -%}
{% if version %}\
## {{ version | trim_start_matches(pat="v") }}
{% else %}\
## Unreleased
{% endif %}\
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | striptags | trim | upper_first }}
{% for commit in commits %}
- {% if commit.scope %}**{{ commit.scope }}**: {% endif %}\
{{ commit.message | upper_first }}\
{% if commit.github.pr_number %} \
([#{{ commit.github.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.github.pr_number }}))\
{% endif %}\
{%- if commit.github.username and commit.github.username != "zarzet" %} by [@{{ commit.github.username }}](https://github.com/{{ commit.github.username }}){%- endif %}
{%- endfor %}
{% endfor %}
{%- if github.contributors | filter(attribute="is_first_time", value=true) | length != 0 %}
### New Contributors
{%- for contributor in github.contributors | filter(attribute="is_first_time", value=true) %}
* @{{ contributor.username }} made their first contribution
{%- if contributor.pr_number %} in \
[#{{ contributor.pr_number }}]({{ self::remote_url() }}/pull/{{ contributor.pr_number }}) \
{%- endif %}
{%- endfor %}
{%- endif -%}
{% if version %}
{% if previous.version %}
**Full Changelog**: [{{ previous.version }}...{{ version }}]({{ self::remote_url() }}/compare/{{ previous.version }}...{{ version }})
{% endif %}
{% else -%}
{% raw %}\n{% endraw %}
{% endif %}
"""
# Remove leading and trailing whitespace
trim = true
[git]
# Parse conventional commits
conventional_commits = true
filter_unconventional = true
# Process each line of a commit as an individual commit
split_commits = false
# Regex for preprocessing the commit messages
commit_preprocessors = [
# Strip conventional commit prefix for cleaner messages
# (group header already shows the type)
]
# Regex for parsing and grouping commits
commit_parsers = [
# Skip noise: translation commits from Crowdin
{ message = "^New translations", skip = true },
{ message = "^Update source file", skip = true },
# Skip merge commits
{ message = "^Merge", skip = true },
# Skip version bump commits
{ message = "^v\\d+", skip = true },
{ message = "^chore: update VirusTotal", skip = true },
# Group by conventional commit type
{ message = "^feat", group = "<!-- 0 -->New Features" },
{ message = "^fix", group = "<!-- 1 -->Bug Fixes" },
{ message = "^perf", group = "<!-- 2 -->Performance" },
{ message = "^refactor", group = "<!-- 3 -->Refactoring" },
{ message = "^doc", group = "<!-- 4 -->Documentation" },
{ message = "^style", group = "<!-- 5 -->Styling" },
{ message = "^test", group = "<!-- 6 -->Testing" },
{ message = "^chore\\(deps\\)", group = "<!-- 7 -->Dependencies" },
{ message = "^chore\\(l10n\\)", skip = true },
{ message = "^chore|^ci", group = "<!-- 8 -->Chores" },
]
# Protect breaking changes from being skipped
protect_breaking_commits = true
# Filter out commits by matching patterns
filter_commits = false
# Tag pattern for version detection
tag_pattern = "v[0-9].*"
# Sort commits by newest first
sort_commits = "newest"
[remote.github]
owner = "zarzet"
repo = "SpotiFLAC-Mobile"
+25 -2
View File
@@ -1566,7 +1566,14 @@ func base64StdDecode(dst, src []byte) (int, error) {
}
func extractAnyCoverArt(filePath string) ([]byte, string, error) {
return extractAnyCoverArtWithHint(filePath, "")
}
func extractAnyCoverArtWithHint(filePath, displayNameHint string) ([]byte, string, error) {
ext := strings.ToLower(filepath.Ext(filePath))
if ext == "" {
ext = strings.ToLower(filepath.Ext(displayNameHint))
}
switch ext {
case ".flac":
@@ -1587,7 +1594,19 @@ func extractAnyCoverArt(filePath string) ([]byte, string, error) {
return extractOggCoverArt(filePath)
case ".m4a":
return nil, "", fmt.Errorf("M4A cover extraction not yet supported")
data, err := extractCoverFromM4A(filePath)
if err != nil {
return nil, "", err
}
mimeType := "image/jpeg"
if len(data) >= 8 &&
data[0] == 0x89 &&
data[1] == 0x50 &&
data[2] == 0x4E &&
data[3] == 0x47 {
mimeType = "image/png"
}
return data, mimeType, nil
default:
return nil, "", fmt.Errorf("unsupported format: %s", ext)
@@ -1595,6 +1614,10 @@ func extractAnyCoverArt(filePath string) ([]byte, string, error) {
}
func SaveCoverToCache(filePath, cacheDir string) (string, error) {
return SaveCoverToCacheWithHint(filePath, "", cacheDir)
}
func SaveCoverToCacheWithHint(filePath, displayNameHint, cacheDir string) (string, error) {
cacheKey := filePath
if stat, err := os.Stat(filePath); err == nil {
cacheKey = fmt.Sprintf("%s|%d|%d", filePath, stat.Size(), stat.ModTime().UnixNano())
@@ -1611,7 +1634,7 @@ func SaveCoverToCache(filePath, cacheDir string) (string, error) {
return pngPath, nil
}
imageData, mimeType, err := extractAnyCoverArt(filePath)
imageData, mimeType, err := extractAnyCoverArtWithHint(filePath, displayNameHint)
if err != nil {
return "", err
}
-1
View File
@@ -104,7 +104,6 @@ func upgradeDeezerCover(coverURL string) string {
return coverURL
}
// Replace any size pattern with 1800x1800
upgraded := deezerSizeRegex.ReplaceAllString(coverURL, "/1800x1800-000000-80-0-0.jpg")
if upgraded != coverURL {
GoLog("[Cover] Deezer: upgraded to 1800x1800")
+590
View File
@@ -0,0 +1,590 @@
package gobackend
import (
"bufio"
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
)
// CueSheet represents a parsed .cue file
type CueSheet struct {
// Album-level metadata
Performer string `json:"performer"`
Title string `json:"title"`
FileName string `json:"file_name"`
FileType string `json:"file_type"` // WAVE, FLAC, MP3, AIFF, etc.
Genre string `json:"genre,omitempty"`
Date string `json:"date,omitempty"`
Comment string `json:"comment,omitempty"`
Composer string `json:"composer,omitempty"`
Tracks []CueTrack `json:"tracks"`
}
// CueTrack represents a single track in a cue sheet
type CueTrack struct {
Number int `json:"number"`
Title string `json:"title"`
Performer string `json:"performer"`
ISRC string `json:"isrc,omitempty"`
Composer string `json:"composer,omitempty"`
// Index positions in seconds (fractional)
StartTime float64 `json:"start_time"` // INDEX 01 in seconds
PreGap float64 `json:"pre_gap"` // INDEX 00 in seconds (or -1 if not present)
}
// CueSplitInfo represents the information needed to split a CUE+audio file
type CueSplitInfo struct {
CuePath string `json:"cue_path"`
AudioPath string `json:"audio_path"`
Album string `json:"album"`
Artist string `json:"artist"`
Genre string `json:"genre,omitempty"`
Date string `json:"date,omitempty"`
Tracks []CueSplitTrack `json:"tracks"`
}
// CueSplitTrack has the FFmpeg split parameters for a single track
type CueSplitTrack struct {
Number int `json:"number"`
Title string `json:"title"`
Artist string `json:"artist"`
ISRC string `json:"isrc,omitempty"`
Composer string `json:"composer,omitempty"`
StartSec float64 `json:"start_sec"`
EndSec float64 `json:"end_sec"` // -1 means until end of file
}
var (
reRemCommand = regexp.MustCompile(`^REM\s+(\S+)\s+(.+)$`)
reQuoted = regexp.MustCompile(`"([^"]*)"`)
)
// ParseCueFile parses a .cue file and returns a CueSheet
func ParseCueFile(cuePath string) (*CueSheet, error) {
f, err := os.Open(cuePath)
if err != nil {
return nil, fmt.Errorf("failed to open cue file: %w", err)
}
defer f.Close()
sheet := &CueSheet{}
var currentTrack *CueTrack
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
// Handle BOM at start of file
if strings.HasPrefix(line, "\xef\xbb\xbf") {
line = strings.TrimPrefix(line, "\xef\xbb\xbf")
line = strings.TrimSpace(line)
}
upper := strings.ToUpper(line)
// REM commands (album-level metadata)
if strings.HasPrefix(upper, "REM ") {
matches := reRemCommand.FindStringSubmatch(line)
if len(matches) == 3 {
key := strings.ToUpper(matches[1])
value := unquoteCue(matches[2])
switch key {
case "GENRE":
sheet.Genre = value
case "DATE":
sheet.Date = value
case "COMMENT":
sheet.Comment = value
case "COMPOSER":
if currentTrack != nil {
currentTrack.Composer = value
} else {
sheet.Composer = value
}
}
}
continue
}
if strings.HasPrefix(upper, "PERFORMER ") {
value := unquoteCue(line[len("PERFORMER "):])
if currentTrack != nil {
currentTrack.Performer = value
} else {
sheet.Performer = value
}
continue
}
if strings.HasPrefix(upper, "TITLE ") {
value := unquoteCue(line[len("TITLE "):])
if currentTrack != nil {
currentTrack.Title = value
} else {
sheet.Title = value
}
continue
}
if strings.HasPrefix(upper, "FILE ") {
rest := line[len("FILE "):]
// Extract filename and type
// Format: FILE "filename.flac" WAVE
// or: FILE filename.flac WAVE
fname, ftype := parseCueFileLine(rest)
sheet.FileName = fname
sheet.FileType = ftype
continue
}
if strings.HasPrefix(upper, "TRACK ") {
// Save previous track
if currentTrack != nil {
sheet.Tracks = append(sheet.Tracks, *currentTrack)
}
parts := strings.Fields(line)
trackNum := 0
if len(parts) >= 2 {
trackNum, _ = strconv.Atoi(parts[1])
}
currentTrack = &CueTrack{
Number: trackNum,
PreGap: -1,
}
continue
}
if strings.HasPrefix(upper, "INDEX ") && currentTrack != nil {
parts := strings.Fields(line)
if len(parts) >= 3 {
indexNum, _ := strconv.Atoi(parts[1])
timeSec := parseCueTimestamp(parts[2])
switch indexNum {
case 0:
currentTrack.PreGap = timeSec
case 1:
currentTrack.StartTime = timeSec
}
}
continue
}
if strings.HasPrefix(upper, "ISRC ") && currentTrack != nil {
currentTrack.ISRC = strings.TrimSpace(line[len("ISRC "):])
continue
}
// SONGWRITER (used as composer sometimes)
if strings.HasPrefix(upper, "SONGWRITER ") {
value := unquoteCue(line[len("SONGWRITER "):])
if currentTrack != nil {
currentTrack.Composer = value
} else {
sheet.Composer = value
}
continue
}
}
// Don't forget the last track
if currentTrack != nil {
sheet.Tracks = append(sheet.Tracks, *currentTrack)
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("error reading cue file: %w", err)
}
if len(sheet.Tracks) == 0 {
return nil, fmt.Errorf("no tracks found in cue file")
}
return sheet, nil
}
// parseCueTimestamp converts MM:SS:FF (frames at 75fps) to seconds
func parseCueTimestamp(ts string) float64 {
parts := strings.Split(ts, ":")
if len(parts) != 3 {
return 0
}
minutes, _ := strconv.Atoi(parts[0])
seconds, _ := strconv.Atoi(parts[1])
frames, _ := strconv.Atoi(parts[2])
return float64(minutes)*60 + float64(seconds) + float64(frames)/75.0
}
// formatCueTimestamp converts seconds to HH:MM:SS.mmm format for FFmpeg
func formatCueTimestamp(seconds float64) string {
if seconds < 0 {
return "0"
}
hours := int(seconds) / 3600
mins := (int(seconds) % 3600) / 60
secs := seconds - float64(hours*3600) - float64(mins*60)
return fmt.Sprintf("%02d:%02d:%06.3f", hours, mins, secs)
}
// unquoteCue removes surrounding quotes from a CUE value
func unquoteCue(s string) string {
s = strings.TrimSpace(s)
if matches := reQuoted.FindStringSubmatch(s); len(matches) == 2 {
return matches[1]
}
return s
}
// parseCueFileLine parses the FILE command's filename and type
func parseCueFileLine(rest string) (string, string) {
rest = strings.TrimSpace(rest)
var filename, ftype string
if strings.HasPrefix(rest, "\"") {
// Quoted filename
endQuote := strings.Index(rest[1:], "\"")
if endQuote >= 0 {
filename = rest[1 : endQuote+1]
remaining := strings.TrimSpace(rest[endQuote+2:])
ftype = remaining
} else {
filename = rest
}
} else {
// Unquoted filename - last word is the type
parts := strings.Fields(rest)
if len(parts) >= 2 {
ftype = parts[len(parts)-1]
filename = strings.Join(parts[:len(parts)-1], " ")
} else if len(parts) == 1 {
filename = parts[0]
}
}
return filename, strings.TrimSpace(ftype)
}
// ResolveCueAudioPath finds the actual audio file referenced by a .cue sheet.
// It checks relative to the cue file's directory.
func ResolveCueAudioPath(cuePath string, cueFileName string) string {
cueDir := filepath.Dir(cuePath)
// 1. Try the exact filename from the .cue
candidate := filepath.Join(cueDir, cueFileName)
if _, err := os.Stat(candidate); err == nil {
return candidate
}
// 2. Try common case variations
baseName := strings.TrimSuffix(cueFileName, filepath.Ext(cueFileName))
commonExts := []string{".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a"}
for _, ext := range commonExts {
candidate = filepath.Join(cueDir, baseName+ext)
if _, err := os.Stat(candidate); err == nil {
return candidate
}
// Try uppercase ext
candidate = filepath.Join(cueDir, baseName+strings.ToUpper(ext))
if _, err := os.Stat(candidate); err == nil {
return candidate
}
}
// 3. Try to find any audio file with the same base name as the .cue file
cueBase := strings.TrimSuffix(filepath.Base(cuePath), filepath.Ext(cuePath))
for _, ext := range commonExts {
candidate = filepath.Join(cueDir, cueBase+ext)
if _, err := os.Stat(candidate); err == nil {
return candidate
}
}
// 4. If there's only one audio file in the directory, use that
entries, err := os.ReadDir(cueDir)
if err == nil {
audioExts := map[string]bool{
".flac": true, ".wav": true, ".ape": true, ".mp3": true,
".ogg": true, ".wv": true, ".m4a": true, ".aiff": true,
}
var audioFiles []string
for _, entry := range entries {
if entry.IsDir() {
continue
}
ext := strings.ToLower(filepath.Ext(entry.Name()))
if audioExts[ext] {
audioFiles = append(audioFiles, filepath.Join(cueDir, entry.Name()))
}
}
if len(audioFiles) == 1 {
return audioFiles[0]
}
}
return ""
}
// BuildCueSplitInfo creates the split information from a parsed CUE sheet.
// This is returned to the Dart side so FFmpeg can perform the splitting.
// audioDir, if non-empty, overrides the directory for audio file resolution.
func BuildCueSplitInfo(cuePath string, sheet *CueSheet, audioDir string) (*CueSplitInfo, error) {
resolveDir := cuePath
if audioDir != "" {
// Create a virtual path in audioDir so ResolveCueAudioPath looks there
resolveDir = filepath.Join(audioDir, filepath.Base(cuePath))
}
audioPath := ResolveCueAudioPath(resolveDir, sheet.FileName)
if audioPath == "" {
return nil, fmt.Errorf("audio file not found for cue sheet: %s (referenced: %s)", cuePath, sheet.FileName)
}
info := &CueSplitInfo{
CuePath: cuePath,
AudioPath: audioPath,
Album: sheet.Title,
Artist: sheet.Performer,
Genre: sheet.Genre,
Date: sheet.Date,
}
for i, track := range sheet.Tracks {
performer := track.Performer
if performer == "" {
performer = sheet.Performer
}
composer := track.Composer
if composer == "" {
composer = sheet.Composer
}
// End time is the start of the next track, or -1 for the last track
endSec := float64(-1)
if i+1 < len(sheet.Tracks) {
nextTrack := sheet.Tracks[i+1]
// Use pre-gap of next track if available, otherwise its start time
if nextTrack.PreGap >= 0 {
endSec = nextTrack.PreGap
} else {
endSec = nextTrack.StartTime
}
}
info.Tracks = append(info.Tracks, CueSplitTrack{
Number: track.Number,
Title: track.Title,
Artist: performer,
ISRC: track.ISRC,
Composer: composer,
StartSec: track.StartTime,
EndSec: endSec,
})
}
return info, nil
}
// ParseCueFileJSON parses a .cue file and returns JSON with split info.
// This is the main entry point called from Dart via the platform bridge.
// audioDir, if non-empty, overrides the directory used for resolving the
// referenced audio file (useful when the .cue was copied to a temp dir
// but the audio still lives in the original location, e.g. SAF).
func ParseCueFileJSON(cuePath string, audioDir string) (string, error) {
sheet, err := ParseCueFile(cuePath)
if err != nil {
return "", fmt.Errorf("failed to parse cue file: %w", err)
}
info, err := BuildCueSplitInfo(cuePath, sheet, audioDir)
if err != nil {
return "", err
}
jsonBytes, err := json.Marshal(info)
if err != nil {
return "", fmt.Errorf("failed to marshal cue split info: %w", err)
}
return string(jsonBytes), nil
}
// ScanCueFileForLibrary parses a .cue file and returns multiple LibraryScanResult
// entries, one per track. This is used by the library scanner to populate the
// library with individual track entries from a single CUE+FLAC album.
func ScanCueFileForLibrary(cuePath string, scanTime string) ([]LibraryScanResult, error) {
sheet, err := ParseCueFile(cuePath)
if err != nil {
return nil, err
}
audioPath, err := resolveCueAudioPathForLibrary(cuePath, sheet, "")
if err != nil {
return nil, err
}
return scanCueSheetForLibrary(cuePath, sheet, audioPath, "", 0, scanTime)
}
// ScanCueFileForLibraryExt is like ScanCueFileForLibrary but with extra parameters
// for SAF (Storage Access Framework) scenarios:
// - audioDir: if non-empty, overrides the directory used to find the audio file
// - virtualPathPrefix: if non-empty, used instead of cuePath as the base for
// virtual file paths (e.g. a content:// URI). IDs are also based on this.
// - fileModTime: if > 0, used as the FileModTime for all results instead of
// stat-ing the cuePath on disk (useful when the real file lives behind SAF)
func ScanCueFileForLibraryExt(cuePath, audioDir, virtualPathPrefix string, fileModTime int64, scanTime string) ([]LibraryScanResult, error) {
sheet, err := ParseCueFile(cuePath)
if err != nil {
return nil, err
}
audioPath, err := resolveCueAudioPathForLibrary(cuePath, sheet, audioDir)
if err != nil {
return nil, err
}
return scanCueSheetForLibrary(cuePath, sheet, audioPath, virtualPathPrefix, fileModTime, scanTime)
}
func resolveCueAudioPathForLibrary(cuePath string, sheet *CueSheet, audioDir string) (string, error) {
if sheet == nil {
return "", fmt.Errorf("cue sheet is nil for %s", cuePath)
}
resolveBase := cuePath
if audioDir != "" {
resolveBase = filepath.Join(audioDir, filepath.Base(cuePath))
}
audioPath := ResolveCueAudioPath(resolveBase, sheet.FileName)
if audioPath == "" {
return "", fmt.Errorf("audio file not found for cue: %s (referenced: %s)", cuePath, sheet.FileName)
}
return audioPath, nil
}
func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualPathPrefix string, fileModTime int64, scanTime string) ([]LibraryScanResult, error) {
if sheet == nil {
return nil, fmt.Errorf("cue sheet is nil for %s", cuePath)
}
// Try to get quality info from the audio file
var bitDepth, sampleRate int
var totalDurationSec float64
audioExt := strings.ToLower(filepath.Ext(audioPath))
switch audioExt {
case ".flac":
quality, qErr := GetAudioQuality(audioPath)
if qErr == nil {
bitDepth = quality.BitDepth
sampleRate = quality.SampleRate
if quality.SampleRate > 0 && quality.TotalSamples > 0 {
totalDurationSec = float64(quality.TotalSamples) / float64(quality.SampleRate)
}
}
case ".mp3":
quality, qErr := GetMP3Quality(audioPath)
if qErr == nil {
sampleRate = quality.SampleRate
totalDurationSec = float64(quality.Duration)
}
}
// Extract cover from audio file for all tracks
var coverPath string
libraryCoverCacheMu.RLock()
coverCacheDir := libraryCoverCacheDir
libraryCoverCacheMu.RUnlock()
if coverCacheDir != "" {
cp, err := SaveCoverToCache(audioPath, coverCacheDir)
if err == nil && cp != "" {
coverPath = cp
}
}
// Determine the base path for virtual paths and IDs
pathBase := cuePath
if virtualPathPrefix != "" {
pathBase = virtualPathPrefix
}
// Determine fileModTime
modTime := fileModTime
if modTime <= 0 {
if info, err := os.Stat(cuePath); err == nil {
modTime = info.ModTime().UnixMilli()
}
}
var results []LibraryScanResult
for i, track := range sheet.Tracks {
performer := track.Performer
if performer == "" {
performer = sheet.Performer
}
if performer == "" {
performer = "Unknown Artist"
}
title := track.Title
if title == "" {
title = fmt.Sprintf("Track %02d", track.Number)
}
album := sheet.Title
if album == "" {
album = "Unknown Album"
}
// Calculate duration for this track
var duration int
if i+1 < len(sheet.Tracks) {
nextStart := sheet.Tracks[i+1].StartTime
if sheet.Tracks[i+1].PreGap >= 0 {
nextStart = sheet.Tracks[i+1].PreGap
}
duration = int(nextStart - track.StartTime)
} else if totalDurationSec > 0 {
duration = int(totalDurationSec - track.StartTime)
}
id := generateLibraryID(fmt.Sprintf("%s#track%d", pathBase, track.Number))
// Use a virtual file path that includes the track number to ensure
// uniqueness in the database (file_path has a UNIQUE constraint).
// Format: /path/to/album.cue#track01 or content://...album.cue#track01
virtualFilePath := fmt.Sprintf("%s#track%02d", pathBase, track.Number)
result := LibraryScanResult{
ID: id,
TrackName: title,
ArtistName: performer,
AlbumName: album,
AlbumArtist: sheet.Performer,
FilePath: virtualFilePath,
CoverPath: coverPath,
ScannedAt: scanTime,
ISRC: track.ISRC,
TrackNumber: track.Number,
DiscNumber: 1,
Duration: duration,
ReleaseDate: sheet.Date,
BitDepth: bitDepth,
SampleRate: sampleRate,
Genre: sheet.Genre,
Format: "cue+" + strings.TrimPrefix(audioExt, "."),
}
result.FileModTime = modTime
results = append(results, result)
}
return results, nil
}
+8 -5
View File
@@ -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
}
+57 -12
View File
@@ -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"`
@@ -394,11 +433,6 @@ func downloadFromDeezer(req DownloadRequest) (DeezerDownloadResult, error) {
}
}
spotifyURL, err := resolveSpotifyURLForYoinkify(req)
if err != nil {
return DeezerDownloadResult{}, err
}
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
"title": req.TrackName,
"artist": req.ArtistName,
@@ -461,6 +495,17 @@ func downloadFromDeezer(req DownloadRequest) (DeezerDownloadResult, error) {
}
if downloadErr != nil || deezerURLErr != nil {
spotifyURL, err := resolveSpotifyURLForYoinkify(req)
if err != nil {
if deezerURLErr != nil {
return DeezerDownloadResult{}, fmt.Errorf(
"deezer download failed: direct Deezer resolution error: %v; Yoinkify fallback error: %w",
deezerURLErr,
err,
)
}
return DeezerDownloadResult{}, err
}
downloadErr = deezerClient.DownloadFromYoinkify(spotifyURL, outputPath, req.OutputFD, req.ItemID)
if downloadErr != nil {
if errors.Is(downloadErr, ErrDownloadCancelled) {
-1
View File
@@ -34,7 +34,6 @@ func GetISRCIndex(outputDir string) *ISRCIndex {
return idx
}
// Slow path: need to build index
// Use per-directory mutex to prevent multiple goroutines from building simultaneously
buildLock, _ := isrcBuildingMu.LoadOrStore(outputDir, &sync.Mutex{})
mu := buildLock.(*sync.Mutex)
+424 -282
View File
@@ -32,126 +32,6 @@ func ParseSpotifyURL(url string) (string, error) {
return string(jsonBytes), nil
}
func SetSpotifyAPICredentials(clientID, clientSecret string) {
SetSpotifyCredentials(clientID, clientSecret)
}
func CheckSpotifyCredentials() bool {
return HasSpotifyCredentials()
}
func GetSpotifyMetadata(spotifyURL string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
client, err := NewSpotifyMetadataClient()
if err != nil {
if shouldTrySpotFetchFallback(err) {
data, apiErr := GetSpotifyDataWithAPI(ctx, spotifyURL, DefaultSpotFetchAPIBaseURL)
if apiErr == nil {
jsonBytes, marshalErr := json.Marshal(data)
if marshalErr != nil {
return "", marshalErr
}
return string(jsonBytes), nil
}
}
return "", err
}
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
if err != nil {
if shouldTrySpotFetchFallback(err) {
fallbackData, apiErr := GetSpotifyDataWithAPI(ctx, spotifyURL, DefaultSpotFetchAPIBaseURL)
if apiErr == nil {
jsonBytes, marshalErr := json.Marshal(fallbackData)
if marshalErr != nil {
return "", marshalErr
}
return string(jsonBytes), nil
}
}
return "", err
}
jsonBytes, err := json.Marshal(data)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
func SearchSpotify(query string, limit int) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
client, err := NewSpotifyMetadataClient()
if err != nil {
return "", err
}
results, err := client.SearchTracks(ctx, query, limit)
if err != nil {
return "", err
}
jsonBytes, err := json.Marshal(results)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
func SearchSpotifyAll(query string, trackLimit, artistLimit int) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
client, err := NewSpotifyMetadataClient()
if err != nil {
return "", err
}
results, err := client.SearchAll(ctx, query, trackLimit, artistLimit)
if err != nil {
return "", err
}
jsonBytes, err := json.Marshal(results)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
func GetSpotifyRelatedArtists(artistID string, limit int) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
client, err := NewSpotifyMetadataClient()
if err != nil {
return "", err
}
normalizedArtistID := strings.TrimSpace(strings.TrimPrefix(artistID, "spotify:"))
if normalizedArtistID == "" {
return "", fmt.Errorf("invalid Spotify artist ID")
}
artists, err := client.GetRelatedArtists(ctx, normalizedArtistID, limit)
if err != nil {
return "", err
}
resp := map[string]interface{}{
"artists": artists,
}
jsonBytes, err := json.Marshal(resp)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
func CheckAvailability(spotifyID, isrc string) (string, error) {
client := NewSongLinkClient()
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
@@ -248,6 +128,7 @@ type DownloadResult struct {
TrackNumber int
DiscNumber int
ISRC string
CoverURL string
Genre string
Label string
Copyright string
@@ -255,6 +136,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,
@@ -273,25 +184,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 == "" {
@@ -313,6 +215,11 @@ func buildDownloadSuccessResponse(
copyright = req.Copyright
}
coverURL := strings.TrimSpace(result.CoverURL)
if coverURL == "" {
coverURL = strings.TrimSpace(req.CoverURL)
}
return DownloadResponse{
Success: true,
Message: message,
@@ -329,7 +236,7 @@ func buildDownloadSuccessResponse(
TrackNumber: trackNumber,
DiscNumber: discNumber,
ISRC: isrc,
CoverURL: req.CoverURL,
CoverURL: coverURL,
Genre: genre,
Label: label,
Copyright: copyright,
@@ -382,7 +289,7 @@ func enrichRequestExtendedMetadata(req *DownloadRequest) {
return
}
if req.ISRC == "" || (req.Genre != "" && req.Label != "") {
if req.ISRC == "" || (req.Genre != "" && req.Label != "" && req.Copyright != "") {
return
}
@@ -404,8 +311,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)
}
}
@@ -474,6 +384,7 @@ func DownloadTrack(requestJSON string) (string, error) {
TrackNumber: qobuzResult.TrackNumber,
DiscNumber: qobuzResult.DiscNumber,
ISRC: qobuzResult.ISRC,
CoverURL: qobuzResult.CoverURL,
LyricsLRC: qobuzResult.LyricsLRC,
}
}
@@ -682,6 +593,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
TrackNumber: qobuzResult.TrackNumber,
DiscNumber: qobuzResult.DiscNumber,
ISRC: qobuzResult.ISRC,
CoverURL: qobuzResult.CoverURL,
LyricsLRC: qobuzResult.LyricsLRC,
}
} else if !errors.Is(qobuzErr, ErrDownloadCancelled) {
@@ -835,6 +747,26 @@ func ReadFileMetadata(filePath string) (string, error) {
}
}
} else if isM4A {
meta, err := ReadM4ATags(filePath)
if err == nil && meta != nil {
result["title"] = meta.Title
result["artist"] = meta.Artist
result["album"] = meta.Album
result["album_artist"] = meta.AlbumArtist
result["date"] = meta.Date
if meta.Date == "" {
result["date"] = meta.Year
}
result["track_number"] = meta.TrackNumber
result["disc_number"] = meta.DiscNumber
result["isrc"] = meta.ISRC
result["lyrics"] = meta.Lyrics
result["genre"] = meta.Genre
result["label"] = meta.Label
result["copyright"] = meta.Copyright
result["composer"] = meta.Composer
result["comment"] = meta.Comment
}
quality, qualityErr := GetM4AQuality(filePath)
if qualityErr == nil {
result["bit_depth"] = quality.BitDepth
@@ -901,6 +833,32 @@ func ReadFileMetadata(filePath string) (string, error) {
return string(jsonBytes), nil
}
// ParseCueSheet parses a .cue file and returns JSON with split information.
// This is called from Dart to get track listing and timing data for CUE splitting.
// audioDir, if non-empty, overrides the directory used for resolving the
// referenced audio file (useful for SAF temp file scenarios).
func ParseCueSheet(cuePath string, audioDir string) (string, error) {
return ParseCueFileJSON(cuePath, audioDir)
}
// ScanCueSheetForLibrary parses a .cue file and returns a JSON array of
// LibraryScanResult entries (one per track). This is the SAF-friendly variant:
// - audioDir overrides where the referenced audio file is resolved
// - virtualPathPrefix replaces cuePath in filePath / id fields (e.g. a content:// URI)
// - fileModTime is stamped on every result (pass 0 to stat cuePath instead)
func ScanCueSheetForLibrary(cuePath, audioDir, virtualPathPrefix string, fileModTime int64) (string, error) {
scanTime := time.Now().UTC().Format(time.RFC3339)
results, err := ScanCueFileForLibraryExt(cuePath, audioDir, virtualPathPrefix, fileModTime, scanTime)
if err != nil {
return "[]", err
}
jsonBytes, err := json.Marshal(results)
if err != nil {
return "[]", fmt.Errorf("failed to marshal cue scan results: %w", err)
}
return string(jsonBytes), nil
}
// EditFileMetadata writes metadata to an audio file.
// For FLAC files, uses native Go FLAC library.
// For MP3/Opus, returns the metadata map so Dart can use FFmpeg.
@@ -1197,6 +1155,36 @@ func SearchDeezerAll(query string, trackLimit, artistLimit int, filter string) (
return string(jsonBytes), nil
}
func SearchTidalAll(query string, trackLimit, artistLimit int, filter string) (string, error) {
downloader := NewTidalDownloader()
results, err := downloader.SearchAll(query, trackLimit, artistLimit, filter)
if err != nil {
return "", err
}
jsonBytes, err := json.Marshal(results)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
func SearchQobuzAll(query string, trackLimit, artistLimit int, filter string) (string, error) {
downloader := NewQobuzDownloader()
results, err := downloader.SearchAll(query, trackLimit, artistLimit, filter)
if err != nil {
return "", err
}
jsonBytes, err := json.Marshal(results)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
func GetDeezerRelatedArtists(artistID string, limit int) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
@@ -1250,6 +1238,66 @@ func GetDeezerMetadata(resourceType, resourceID string) (string, error) {
return string(jsonBytes), nil
}
func GetQobuzMetadata(resourceType, resourceID string) (string, error) {
downloader := NewQobuzDownloader()
var data interface{}
var err error
switch resourceType {
case "track":
data, err = downloader.GetTrackMetadata(resourceID)
case "album":
data, err = downloader.GetAlbumMetadata(resourceID)
case "artist":
data, err = downloader.GetArtistMetadata(resourceID)
case "playlist":
data, err = downloader.GetPlaylistMetadata(resourceID)
default:
return "", fmt.Errorf("unsupported Qobuz resource type: %s", resourceType)
}
if err != nil {
return "", err
}
jsonBytes, err := json.Marshal(data)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
func GetTidalMetadata(resourceType, resourceID string) (string, error) {
downloader := NewTidalDownloader()
var data interface{}
var err error
switch resourceType {
case "track":
data, err = downloader.GetTrackMetadata(resourceID)
case "album":
data, err = downloader.GetAlbumMetadata(resourceID)
case "artist":
data, err = downloader.GetArtistMetadata(resourceID)
case "playlist":
data, err = downloader.GetPlaylistMetadata(resourceID)
default:
return "", fmt.Errorf("unsupported Tidal resource type: %s", resourceType)
}
if err != nil {
return "", err
}
jsonBytes, err := json.Marshal(data)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
func ParseDeezerURLExport(url string) (string, error) {
resourceType, resourceID, err := parseDeezerURL(url)
if err != nil {
@@ -1269,6 +1317,25 @@ func ParseDeezerURLExport(url string) (string, error) {
return string(jsonBytes), nil
}
func ParseQobuzURLExport(url string) (string, error) {
resourceType, resourceID, err := parseQobuzURL(url)
if err != nil {
return "", err
}
result := map[string]string{
"type": resourceType,
"id": resourceID,
}
jsonBytes, err := json.Marshal(result)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
func ParseTidalURLExport(url string) (string, error) {
resourceType, resourceID, err := parseTidalURL(url)
if err != nil {
@@ -1329,10 +1396,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 {
@@ -1352,7 +1416,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
}
@@ -1360,6 +1425,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()
@@ -1405,7 +1519,6 @@ func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
return string(jsonBytes), nil
}
// For artists/playlists, SongLink doesn't provide direct mapping
return "", fmt.Errorf("Spotify to Deezer conversion only supported for tracks and albums. Please search by name for %s", resourceType)
}
@@ -1413,28 +1526,6 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
var spotifyErr error
client, err := NewSpotifyMetadataClient()
if err != nil {
LogWarn("Spotify", "Credentials not configured, falling back to Deezer")
spotifyErr = err
} else {
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
if err == nil {
jsonBytes, err := json.Marshal(data)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
spotifyErr = err
if !shouldTrySpotFetchFallback(err) {
return "", err
}
}
spotFetchData, apiErr := GetSpotifyDataWithAPI(ctx, spotifyURL, DefaultSpotFetchAPIBaseURL)
if apiErr == nil {
GoLog("[Fallback] Spotify metadata fetched via SpotFetch API\n")
@@ -1448,9 +1539,6 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
parsed, parseErr := parseSpotifyURI(spotifyURL)
if parseErr != nil {
if spotifyErr != nil {
return "", fmt.Errorf("spotify failed (%v), SpotFetch fallback failed (%v), and URL parsing failed: %w", spotifyErr, apiErr, parseErr)
}
return "", fmt.Errorf("SpotFetch fallback failed (%v) and URL parsing failed: %w", apiErr, parseErr)
}
@@ -1461,15 +1549,9 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
}
if parsed.Type == "artist" {
if spotifyErr != nil {
return "", fmt.Errorf("spotify metadata unavailable (%v) and SpotFetch fallback failed (%v). Artist pages require Spotify/SpotFetch API", spotifyErr, apiErr)
}
return "", fmt.Errorf("SpotFetch fallback failed (%v). Artist pages require Spotify/SpotFetch API", apiErr)
return "", fmt.Errorf("SpotFetch fallback failed (%v). Artist pages now require SpotFetch or a metadata extension such as spotify-web", apiErr)
}
if spotifyErr != nil {
return "", fmt.Errorf("spotify metadata unavailable (%v), SpotFetch fallback failed (%v), and Deezer conversion is unavailable for playlists", spotifyErr, apiErr)
}
return "", fmt.Errorf("SpotFetch fallback failed (%v), and Deezer conversion is unavailable for playlists", apiErr)
}
@@ -1670,6 +1752,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") {
@@ -1800,114 +1884,58 @@ func ReEnrichFile(requestJSON string) (string, error) {
GoLog("[ReEnrich] Starting re-enrichment for: %s\n", req.FilePath)
// When search_online is true, search for metadata from internet
// Priority: 1) Deezer (reliable, no credentials) 2) Extension providers (spotify-web etc) 3) Spotify built-in API (last resort, deprecated)
// When search_online is true, search for metadata from internet using the
// configured metadata-provider priority.
if req.SearchOnline && req.TrackName != "" && req.ArtistName != "" {
GoLog("[ReEnrich] Searching online metadata for: %s - %s\n", req.TrackName, req.ArtistName)
searchQuery := req.TrackName + " " + req.ArtistName
found := false
// 1) Try Deezer first (reliable, no credentials needed)
GoLog("[ReEnrich] Trying Deezer search...\n")
deezerClient := GetDeezerClient()
{
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
deezerResults, err := deezerClient.SearchAll(ctx, searchQuery, 5, 0, "track")
cancel()
if err == nil && len(deezerResults.Tracks) > 0 {
track := deezerResults.Tracks[0]
GoLog("[ReEnrich] Deezer match: %s - %s (album: %s)\n", track.Name, track.Artists, track.AlbumName)
req.SpotifyID = "deezer:" + track.SpotifyID
req.AlbumName = track.AlbumName
req.AlbumArtist = track.AlbumArtist
req.TrackNumber = track.TrackNumber
req.DiscNumber = track.DiscNumber
req.ReleaseDate = track.ReleaseDate
req.ISRC = track.ISRC
if track.Images != "" {
req.CoverURL = track.Images
}
req.DurationMs = int64(track.DurationMS)
found = true
} else if err != nil {
GoLog("[ReEnrich] Deezer search failed: %v\n", err)
}
}
// 2) Try extension metadata providers (spotify-web etc) if Deezer failed
if !found {
GoLog("[ReEnrich] Trying extension metadata providers...\n")
manager := GetExtensionManager()
extTracks, extErr := manager.SearchTracksWithExtensions(searchQuery, 5)
if extErr == nil && len(extTracks) > 0 {
track := extTracks[0]
GoLog("[ReEnrich] Extension match (%s): %s - %s (album: %s)\n", track.ProviderID, track.Name, track.Artists, track.AlbumName)
if track.SpotifyID != "" {
req.SpotifyID = track.SpotifyID
} else if track.DeezerID != "" {
req.SpotifyID = "deezer:" + track.DeezerID
} else {
req.SpotifyID = track.ID
}
req.AlbumName = track.AlbumName
req.AlbumArtist = track.AlbumArtist
req.TrackNumber = track.TrackNumber
req.DiscNumber = track.DiscNumber
req.ReleaseDate = track.ReleaseDate
req.ISRC = track.ISRC
coverURL := track.ResolvedCoverURL()
if coverURL != "" {
req.CoverURL = coverURL
}
req.DurationMs = int64(track.DurationMS)
if track.Genre != "" {
req.Genre = track.Genre
}
if track.Label != "" {
req.Label = track.Label
}
if track.Copyright != "" {
req.Copyright = track.Copyright
}
found = true
} else if extErr != nil {
GoLog("[ReEnrich] Extension search failed: %v\n", extErr)
}
}
// 3) Try Spotify built-in API as last resort (will be deprecated)
if !found {
GoLog("[ReEnrich] Trying Spotify API (fallback)...\n")
spotifyClient, spotifyErr := NewSpotifyMetadataClient()
if spotifyErr == nil {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
results, err := spotifyClient.SearchTracks(ctx, searchQuery, 5)
cancel()
if err == nil && len(results.Tracks) > 0 {
track := results.Tracks[0]
GoLog("[ReEnrich] Spotify match: %s - %s (album: %s)\n", track.Name, track.Artists, track.AlbumName)
req.SpotifyID = track.SpotifyID
req.AlbumName = track.AlbumName
req.AlbumArtist = track.AlbumArtist
req.TrackNumber = track.TrackNumber
req.DiscNumber = track.DiscNumber
req.ReleaseDate = track.ReleaseDate
req.ISRC = track.ISRC
if track.Images != "" {
req.CoverURL = track.Images
}
req.DurationMs = int64(track.DurationMS)
found = true
} else if err != nil {
GoLog("[ReEnrich] Spotify search failed: %v\n", err)
}
GoLog("[ReEnrich] Trying metadata providers in configured priority...\n")
manager := GetExtensionManager()
tracks, searchErr := manager.SearchTracksWithMetadataProviders(searchQuery, 5, true)
if searchErr == nil && len(tracks) > 0 {
track := tracks[0]
GoLog("[ReEnrich] Metadata match (%s): %s - %s (album: %s)\n", track.ProviderID, track.Name, track.Artists, track.AlbumName)
if track.SpotifyID != "" {
req.SpotifyID = track.SpotifyID
} else if track.DeezerID != "" {
req.SpotifyID = "deezer:" + track.DeezerID
} else if track.QobuzID != "" {
req.SpotifyID = "qobuz:" + track.QobuzID
} else if track.TidalID != "" {
req.SpotifyID = "tidal:" + track.TidalID
} else {
GoLog("[ReEnrich] Spotify client unavailable: %v\n", spotifyErr)
req.SpotifyID = track.ID
}
req.AlbumName = track.AlbumName
req.AlbumArtist = track.AlbumArtist
req.TrackNumber = track.TrackNumber
req.DiscNumber = track.DiscNumber
req.ReleaseDate = track.ReleaseDate
req.ISRC = track.ISRC
coverURL := track.ResolvedCoverURL()
if coverURL != "" {
req.CoverURL = coverURL
}
req.DurationMs = int64(track.DurationMS)
if track.Genre != "" {
req.Genre = track.Genre
}
if track.Label != "" {
req.Label = track.Label
}
if track.Copyright != "" {
req.Copyright = track.Copyright
}
found = true
} else if searchErr != nil {
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()
@@ -1918,7 +1946,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)
}
}
@@ -1987,8 +2018,15 @@ func ReEnrichFile(requestJSON string) (string, error) {
}
}()
// Fetch lyrics
// Preserve existing lyrics when online enrichment does not return a replacement.
var lyricsLRC string
existingLyrics, existingLyricsErr := ExtractLyrics(req.FilePath)
if existingLyricsErr == nil && strings.TrimSpace(existingLyrics) != "" {
lyricsLRC = existingLyrics
GoLog("[ReEnrich] Preserving existing embedded/sidecar lyrics\n")
}
// Fetch lyrics
if req.EmbedLyrics {
client := NewLyricsClient()
durationSec := float64(req.DurationMs) / 1000.0
@@ -2069,7 +2107,6 @@ func ReEnrichFile(requestJSON string) (string, error) {
return string(jsonBytes), nil
}
// MP3/Opus: return metadata map for Dart to use FFmpeg
// Don't cleanup cover temp — Dart needs it for FFmpeg embed
cleanupCover = false
result := map[string]interface{}{
@@ -2305,6 +2342,21 @@ func SearchTracksWithExtensionsJSON(query string, limit int) (string, error) {
return string(jsonBytes), nil
}
func SearchTracksWithMetadataProvidersJSON(query string, limit int, includeExtensions bool) (string, error) {
manager := GetExtensionManager()
tracks, err := manager.SearchTracksWithMetadataProviders(query, limit, includeExtensions)
if err != nil {
return "", err
}
jsonBytes, err := json.Marshal(tracks)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
func DownloadWithExtensionsJSON(requestJSON string) (string, error) {
var req DownloadRequest
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
@@ -2690,6 +2742,28 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
artistResponse["albums"] = albums
}
if len(result.Artist.Releases) > 0 {
releases := make([]map[string]interface{}, len(result.Artist.Releases))
for i, release := range result.Artist.Releases {
releaseType := release.AlbumType
if releaseType == "" {
releaseType = "album"
}
releases[i] = map[string]interface{}{
"id": release.ID,
"name": release.Name,
"artists": release.Artists,
"images": release.CoverURL,
"cover_url": release.CoverURL,
"release_date": release.ReleaseDate,
"total_tracks": release.TotalTracks,
"album_type": releaseType,
"provider_id": release.ProviderID,
}
}
artistResponse["releases"] = releases
}
if len(result.Artist.TopTracks) > 0 {
topTracks := make([]map[string]interface{}, len(result.Artist.TopTracks))
for i, track := range result.Artist.TopTracks {
@@ -2939,6 +3013,27 @@ func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) {
"provider_id": artist.ProviderID,
}
if len(artist.Releases) > 0 {
releases := make([]map[string]interface{}, len(artist.Releases))
for i, release := range artist.Releases {
releaseType := release.AlbumType
if releaseType == "" {
releaseType = "album"
}
releases[i] = map[string]interface{}{
"id": release.ID,
"name": release.Name,
"artists": release.Artists,
"cover_url": release.CoverURL,
"release_date": release.ReleaseDate,
"total_tracks": release.TotalTracks,
"album_type": releaseType,
"provider_id": release.ProviderID,
}
}
response["releases"] = releases
}
if artist.HeaderImage != "" {
response["header_image"] = artist.HeaderImage
}
@@ -3086,6 +3181,45 @@ func InitExtensionStoreJSON(cacheDir string) error {
return nil
}
func SetStoreRegistryURLJSON(registryURL string) error {
store := GetExtensionStore()
if store == nil {
return fmt.Errorf("extension store not initialized")
}
resolved, err := ResolveRegistryURL(registryURL)
if err != nil {
return err
}
if err := requireHTTPSURL(resolved, "registry"); err != nil {
return err
}
store.SetRegistryURL(resolved)
return nil
}
func ClearStoreRegistryURLJSON() error {
store := GetExtensionStore()
if store == nil {
return fmt.Errorf("extension store not initialized")
}
store.SetRegistryURL("")
store.ClearCache()
return nil
}
func GetStoreRegistryURLJSON() (string, error) {
store := GetExtensionStore()
if store == nil {
return "", fmt.Errorf("extension store not initialized")
}
return store.GetRegistryURL(), nil
}
func GetStoreExtensionsJSON(forceRefresh bool) (string, error) {
store := GetExtensionStore()
if store == nil {
@@ -3243,6 +3377,10 @@ func ScanLibraryFolderIncrementalJSON(folderPath, existingFilesJSON string) (str
return ScanLibraryFolderIncremental(folderPath, existingFilesJSON)
}
func ScanLibraryFolderIncrementalFromSnapshotJSON(folderPath, snapshotPath string) (string, error) {
return ScanLibraryFolderIncrementalFromSnapshot(folderPath, snapshotPath)
}
func GetLibraryScanProgressJSON() string {
return GetLibraryScanProgress()
}
@@ -3254,3 +3392,7 @@ func CancelLibraryScanJSON() {
func ReadAudioMetadataJSON(filePath string) (string, error) {
return ReadAudioMetadata(filePath)
}
func ReadAudioMetadataWithHintJSON(filePath, displayName string) (string, error) {
return ReadAudioMetadataWithDisplayName(filePath, displayName)
}
@@ -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"])
}
}
+115
View File
@@ -0,0 +1,115 @@
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)
}
}
func TestBuildDownloadSuccessResponsePrefersProviderCoverURL(t *testing.T) {
req := DownloadRequest{
TrackName: "Track",
ArtistName: "Artist",
AlbumName: "Album",
AlbumArtist: "Artist",
}
result := DownloadResult{
Title: "Track",
Artist: "Artist",
Album: "Album",
CoverURL: "https://cdn.qobuz.test/cover.jpg",
}
resp := buildDownloadSuccessResponse(
req,
result,
"qobuz",
"ok",
"/tmp/test.flac",
false,
)
if resp.CoverURL != result.CoverURL {
t.Fatalf("cover url = %q, want %q", resp.CoverURL, result.CoverURL)
}
}
-2
View File
@@ -151,7 +151,6 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
if exists {
versionCompare := compareVersions(manifest.Version, existingVersion)
if versionCompare > 0 {
// This is an upgrade - call UpgradeExtension
return m.UpgradeExtension(filePath)
} else if versionCompare == 0 {
return nil, fmt.Errorf("Extension '%s' v%s is already installed", existingDisplayName, existingVersion)
@@ -429,7 +428,6 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
SourceDir: dirPath,
}
// Restore enabled state from settings store
store := GetExtensionSettingsStore()
if enabledVal, err := store.Get(manifest.Name, "_enabled"); err == nil {
if enabled, ok := enabledVal.(bool); ok {
+344 -6
View File
@@ -70,6 +70,7 @@ type ExtArtistMetadata struct {
HeaderImage string `json:"header_image,omitempty"`
Listeners int `json:"listeners,omitempty"`
Albums []ExtAlbumMetadata `json:"albums,omitempty"`
Releases []ExtAlbumMetadata `json:"releases,omitempty"`
TopTracks []ExtTrackMetadata `json:"top_tracks,omitempty"`
ProviderID string `json:"provider_id"`
}
@@ -327,6 +328,12 @@ func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat
}
artist.ProviderID = p.extension.ID
for i := range artist.Releases {
artist.Releases[i].ProviderID = p.extension.ID
for j := range artist.Releases[i].Tracks {
artist.Releases[i].Tracks[j].ProviderID = p.extension.ID
}
}
return &artist, nil
}
@@ -484,7 +491,7 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext
return &urlResult, nil
}
const ExtDownloadTimeout = 5 * time.Minute
const ExtDownloadTimeout = DownloadTimeout
func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string, onProgress func(percent int)) (*ExtDownloadResult, error) {
if !p.extension.Manifest.IsDownloadProvider() {
@@ -600,8 +607,30 @@ func (m *ExtensionManager) SearchTracksWithExtensions(query string, limit int) (
return nil, nil
}
var allTracks []ExtTrackMetadata
providerByID := make(map[string]*ExtensionProviderWrapper, len(providers))
orderedProviders := make([]*ExtensionProviderWrapper, 0, len(providers))
for _, provider := range providers {
providerByID[provider.extension.ID] = provider
}
for _, providerID := range GetMetadataProviderPriority() {
if provider := providerByID[providerID]; provider != nil {
orderedProviders = append(orderedProviders, provider)
delete(providerByID, providerID)
}
}
if len(providerByID) > 0 {
remainingIDs := make([]string, 0, len(providerByID))
for providerID := range providerByID {
remainingIDs = append(remainingIDs, providerID)
}
sort.Strings(remainingIDs)
for _, providerID := range remainingIDs {
orderedProviders = append(orderedProviders, providerByID[providerID])
}
}
var allTracks []ExtTrackMetadata
for _, provider := range orderedProviders {
result, err := provider.SearchTracks(query, limit)
if err != nil {
GoLog("[Extension] Search error from %s: %v\n", provider.extension.ID, err)
@@ -621,6 +650,8 @@ var providerPriorityMu sync.RWMutex
var metadataProviderPriority []string
var metadataProviderPriorityMu sync.RWMutex
var searchBuiltInMetadataTracksFunc = searchBuiltInMetadataTracks
func SetProviderPriority(providerIDs []string) {
providerPriorityMu.Lock()
defer providerPriorityMu.Unlock()
@@ -644,8 +675,30 @@ func GetProviderPriority() []string {
func SetMetadataProviderPriority(providerIDs []string) {
metadataProviderPriorityMu.Lock()
defer metadataProviderPriorityMu.Unlock()
metadataProviderPriority = providerIDs
GoLog("[Extension] Metadata provider priority set: %v\n", providerIDs)
sanitized := make([]string, 0, len(providerIDs)+3)
seen := map[string]struct{}{}
for _, providerID := range providerIDs {
providerID = strings.TrimSpace(providerID)
if providerID == "" || providerID == "spotify" {
continue
}
if _, exists := seen[providerID]; exists {
continue
}
seen[providerID] = struct{}{}
sanitized = append(sanitized, providerID)
}
for _, providerID := range []string{"deezer", "qobuz", "tidal"} {
if _, exists := seen[providerID]; exists {
continue
}
seen[providerID] = struct{}{}
sanitized = append(sanitized, providerID)
}
metadataProviderPriority = sanitized
GoLog("[Extension] Metadata provider priority set: %v\n", sanitized)
}
func GetMetadataProviderPriority() []string {
@@ -653,7 +706,7 @@ func GetMetadataProviderPriority() []string {
defer metadataProviderPriorityMu.RUnlock()
if len(metadataProviderPriority) == 0 {
return []string{"deezer", "spotify"}
return []string{"deezer", "qobuz", "tidal"}
}
result := make([]string, len(metadataProviderPriority))
@@ -670,6 +723,165 @@ func isBuiltInProvider(providerID string) bool {
}
}
func normalizeBuiltInMetadataTrack(track TrackMetadata, providerID string) ExtTrackMetadata {
deezerID := ""
tidalID := ""
qobuzID := ""
prefixedID := strings.TrimSpace(track.SpotifyID)
switch providerID {
case "deezer":
deezerID = strings.TrimPrefix(prefixedID, "deezer:")
case "tidal":
tidalID = strings.TrimPrefix(prefixedID, "tidal:")
case "qobuz":
qobuzID = strings.TrimPrefix(prefixedID, "qobuz:")
}
return ExtTrackMetadata{
ID: prefixedID,
Name: track.Name,
Artists: track.Artists,
AlbumName: track.AlbumName,
AlbumArtist: track.AlbumArtist,
DurationMS: track.DurationMS,
CoverURL: track.Images,
Images: track.Images,
ReleaseDate: track.ReleaseDate,
TrackNumber: track.TrackNumber,
DiscNumber: track.DiscNumber,
ISRC: track.ISRC,
ProviderID: providerID,
SpotifyID: prefixedID,
DeezerID: deezerID,
TidalID: tidalID,
QobuzID: qobuzID,
AlbumType: track.AlbumType,
}
}
func metadataTrackDedupKey(track ExtTrackMetadata) string {
if isrc := strings.TrimSpace(track.ISRC); isrc != "" {
return "isrc:" + strings.ToUpper(isrc)
}
if spotifyID := strings.TrimSpace(track.SpotifyID); spotifyID != "" {
return "spotify:" + spotifyID
}
if providerID := strings.TrimSpace(track.ProviderID); providerID != "" && strings.TrimSpace(track.ID) != "" {
return providerID + ":" + strings.TrimSpace(track.ID)
}
return strings.TrimSpace(track.Name) + "|" + strings.TrimSpace(track.Artists)
}
func searchBuiltInMetadataTracks(providerID, query string, limit int) ([]ExtTrackMetadata, error) {
switch providerID {
case "deezer":
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
results, err := GetDeezerClient().SearchAll(ctx, query, limit, 0, "track")
if err != nil {
return nil, err
}
tracks := make([]ExtTrackMetadata, 0, len(results.Tracks))
for _, track := range results.Tracks {
tracks = append(tracks, normalizeBuiltInMetadataTrack(track, "deezer"))
}
return tracks, nil
case "qobuz":
return NewQobuzDownloader().SearchTracks(query, limit)
case "tidal":
return NewTidalDownloader().SearchTracks(query, limit)
default:
return nil, fmt.Errorf("unsupported built-in metadata provider: %s", providerID)
}
}
func (m *ExtensionManager) SearchTracksWithMetadataProviders(query string, limit int, includeExtensions bool) ([]ExtTrackMetadata, error) {
priority := GetMetadataProviderPriority()
if limit <= 0 {
limit = 20
}
extensionProviders := make(map[string]*ExtensionProviderWrapper)
if includeExtensions {
for _, provider := range m.GetMetadataProviders() {
extensionProviders[provider.extension.ID] = provider
}
}
orderedProviderIDs := make([]string, 0, len(priority)+len(extensionProviders))
seenProviderIDs := make(map[string]struct{}, len(priority)+len(extensionProviders))
for _, providerID := range priority {
providerID = strings.TrimSpace(providerID)
if providerID == "" {
continue
}
orderedProviderIDs = append(orderedProviderIDs, providerID)
seenProviderIDs[providerID] = struct{}{}
}
if includeExtensions {
remainingIDs := make([]string, 0, len(extensionProviders))
for providerID := range extensionProviders {
if _, exists := seenProviderIDs[providerID]; exists {
continue
}
remainingIDs = append(remainingIDs, providerID)
}
sort.Strings(remainingIDs)
orderedProviderIDs = append(orderedProviderIDs, remainingIDs...)
}
tracks := make([]ExtTrackMetadata, 0, limit)
seenTracks := make(map[string]struct{})
for _, providerID := range orderedProviderIDs {
var (
providerTracks []ExtTrackMetadata
err error
)
if isBuiltInProvider(providerID) {
providerTracks, err = searchBuiltInMetadataTracksFunc(providerID, query, limit)
} else {
if !includeExtensions {
continue
}
provider := extensionProviders[providerID]
if provider == nil {
continue
}
var result *ExtSearchResult
result, err = provider.SearchTracks(query, limit)
if result != nil {
providerTracks = result.Tracks
}
}
if err != nil {
GoLog("[MetadataSearch] Search error from %s: %v\n", providerID, err)
continue
}
for _, track := range providerTracks {
key := metadataTrackDedupKey(track)
if key == "" {
continue
}
if _, exists := seenTracks[key]; exists {
continue
}
seenTracks[key] = struct{}{}
tracks = append(tracks, track)
if len(tracks) >= limit {
return tracks, nil
}
}
}
return tracks, nil
}
func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, error) {
priority := GetProviderPriority()
extManager := GetExtensionManager()
@@ -765,6 +977,24 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
if enrichedTrack.Artists != "" {
req.ArtistName = enrichedTrack.Artists
}
if enrichedTrack.AlbumName != "" && req.AlbumName == "" {
GoLog("[DownloadWithExtensionFallback] AlbumName from enrichment: %s\n", enrichedTrack.AlbumName)
req.AlbumName = enrichedTrack.AlbumName
}
if enrichedTrack.AlbumArtist != "" && req.AlbumArtist == "" {
req.AlbumArtist = enrichedTrack.AlbumArtist
}
if enrichedTrack.DurationMS > 0 && req.DurationMS == 0 {
GoLog("[DownloadWithExtensionFallback] DurationMS from enrichment: %d\n", enrichedTrack.DurationMS)
req.DurationMS = enrichedTrack.DurationMS
}
if enrichedTrack.CoverURL != "" && req.CoverURL == "" {
req.CoverURL = enrichedTrack.CoverURL
}
if enrichedTrack.ID != "" && req.SpotifyID == "" {
GoLog("[DownloadWithExtensionFallback] Track ID from enrichment: %s\n", enrichedTrack.ID)
req.SpotifyID = enrichedTrack.ID
}
if enrichedTrack.Label != "" && req.Label == "" {
GoLog("[DownloadWithExtensionFallback] Label from enrichment: %s\n", enrichedTrack.Label)
req.Label = enrichedTrack.Label
@@ -785,6 +1015,77 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
}
}
// If key metadata is still missing after extension enrichment, search
// configured metadata providers (Spotify/Deezer/Tidal/Qobuz) — same
// logic that ReEnrichFile uses.
if req.Source != "" && !isBuiltInProvider(strings.ToLower(req.Source)) &&
req.TrackName != "" && req.ArtistName != "" &&
(req.AlbumName == "" || req.ReleaseDate == "" || req.ISRC == "") {
searchQuery := req.TrackName + " " + req.ArtistName
GoLog("[DownloadWithExtensionFallback] Metadata incomplete, searching providers for: %s\n", searchQuery)
tracks, searchErr := extManager.SearchTracksWithMetadataProviders(searchQuery, 5, true)
if searchErr == nil && len(tracks) > 0 {
track := tracks[0]
GoLog("[DownloadWithExtensionFallback] Metadata match (%s): %s - %s (album: %s, date: %s, isrc: %s)\n",
track.ProviderID, track.Name, track.Artists, track.AlbumName, track.ReleaseDate, track.ISRC)
if track.AlbumName != "" && req.AlbumName == "" {
req.AlbumName = track.AlbumName
}
if track.AlbumArtist != "" && req.AlbumArtist == "" {
req.AlbumArtist = track.AlbumArtist
}
if track.ReleaseDate != "" && req.ReleaseDate == "" {
req.ReleaseDate = track.ReleaseDate
}
if track.ISRC != "" && req.ISRC == "" {
req.ISRC = track.ISRC
}
if track.TrackNumber > 0 && req.TrackNumber == 0 {
req.TrackNumber = track.TrackNumber
}
if track.DiscNumber > 0 && req.DiscNumber == 0 {
req.DiscNumber = track.DiscNumber
}
if track.CoverURL != "" && req.CoverURL == "" {
req.CoverURL = track.CoverURL
}
if track.Genre != "" && req.Genre == "" {
req.Genre = track.Genre
}
if track.Label != "" && req.Label == "" {
req.Label = track.Label
}
if track.Copyright != "" && req.Copyright == "" {
req.Copyright = track.Copyright
}
} else if searchErr != nil {
GoLog("[DownloadWithExtensionFallback] Metadata provider search failed (non-fatal): %v\n", searchErr)
}
// 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()
if err == nil && extMeta != nil {
if req.Genre == "" && extMeta.Genre != "" {
req.Genre = extMeta.Genre
}
if req.Label == "" && extMeta.Label != "" {
req.Label = extMeta.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)
}
}
}
if req.Source != "" &&
!isBuiltInProvider(strings.ToLower(req.Source)) &&
(!strictMode || selectedProvider == "" || strings.EqualFold(selectedProvider, req.Source)) {
@@ -878,6 +1179,30 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
}
}
// Always pass enriched metadata from req so Flutter can
// embed it — fills gaps from metadata provider search.
if req.AlbumName != "" && resp.Album == "" {
resp.Album = req.AlbumName
}
if req.AlbumArtist != "" && resp.AlbumArtist == "" {
resp.AlbumArtist = req.AlbumArtist
}
if req.ReleaseDate != "" && resp.ReleaseDate == "" {
resp.ReleaseDate = req.ReleaseDate
}
if req.ISRC != "" && resp.ISRC == "" {
resp.ISRC = req.ISRC
}
if req.TrackNumber > 0 && resp.TrackNumber == 0 {
resp.TrackNumber = req.TrackNumber
}
if req.DiscNumber > 0 && resp.DiscNumber == 0 {
resp.DiscNumber = req.DiscNumber
}
if req.CoverURL != "" && resp.CoverURL == "" {
resp.CoverURL = req.CoverURL
}
return resp, nil
}
@@ -928,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()
@@ -943,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)
}
@@ -1150,6 +1480,7 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
TrackNumber: qobuzResult.TrackNumber,
DiscNumber: qobuzResult.DiscNumber,
ISRC: qobuzResult.ISRC,
CoverURL: qobuzResult.CoverURL,
}
}
err = qobuzErr
@@ -1192,6 +1523,7 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
TrackNumber: result.TrackNumber,
DiscNumber: result.DiscNumber,
ISRC: result.ISRC,
CoverURL: result.CoverURL,
Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
@@ -1431,6 +1763,12 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e
handleResult.Artist.Albums[i].Tracks[j].ProviderID = p.extension.ID
}
}
for i := range handleResult.Artist.Releases {
handleResult.Artist.Releases[i].ProviderID = p.extension.ID
for j := range handleResult.Artist.Releases[i].Tracks {
handleResult.Artist.Releases[i].Tracks[j].ProviderID = p.extension.ID
}
}
for i := range handleResult.Artist.TopTracks {
handleResult.Artist.TopTracks[i].ProviderID = p.extension.ID
}
+68
View File
@@ -0,0 +1,68 @@
package gobackend
import "testing"
func TestSetMetadataProviderPriorityAddsBuiltIns(t *testing.T) {
original := GetMetadataProviderPriority()
defer SetMetadataProviderPriority(original)
SetMetadataProviderPriority([]string{"tidal"})
got := GetMetadataProviderPriority()
want := []string{"tidal", "deezer", "qobuz"}
if len(got) != len(want) {
t.Fatalf("unexpected priority length: got %v want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("unexpected priority at %d: got %v want %v", i, got, want)
}
}
}
func TestSearchTracksWithMetadataProvidersUsesPriorityAndDedupes(t *testing.T) {
originalPriority := GetMetadataProviderPriority()
originalSearch := searchBuiltInMetadataTracksFunc
defer func() {
SetMetadataProviderPriority(originalPriority)
searchBuiltInMetadataTracksFunc = originalSearch
}()
SetMetadataProviderPriority([]string{"qobuz", "tidal", "deezer"})
var calls []string
searchBuiltInMetadataTracksFunc = func(providerID, query string, limit int) ([]ExtTrackMetadata, error) {
calls = append(calls, providerID)
switch providerID {
case "qobuz":
return []ExtTrackMetadata{
{ProviderID: "qobuz", SpotifyID: "qobuz:1", ISRC: "AAA111", Name: "First"},
}, nil
case "tidal":
return []ExtTrackMetadata{
{ProviderID: "tidal", SpotifyID: "tidal:2", ISRC: "AAA111", Name: "Duplicate"},
{ProviderID: "tidal", SpotifyID: "tidal:3", ISRC: "BBB222", Name: "Second"},
}, nil
case "deezer":
return []ExtTrackMetadata{
{ProviderID: "deezer", SpotifyID: "deezer:4", ISRC: "CCC333", Name: "Third"},
}, nil
default:
return nil, nil
}
}
manager := GetExtensionManager()
tracks, err := manager.SearchTracksWithMetadataProviders("query", 3, false)
if err != nil {
t.Fatalf("SearchTracksWithMetadataProviders returned error: %v", err)
}
if len(tracks) != 3 {
t.Fatalf("unexpected track count: got %d want 3", len(tracks))
}
if tracks[0].ProviderID != "qobuz" || tracks[1].ProviderID != "tidal" || tracks[2].ProviderID != "deezer" {
t.Fatalf("unexpected track provider order: %+v", tracks)
}
if len(calls) != 3 || calls[0] != "qobuz" || calls[1] != "tidal" || calls[2] != "deezer" {
t.Fatalf("unexpected provider call order: %v", calls)
}
}
+17 -11
View File
@@ -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 {
+6 -1
View File
@@ -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,
+114 -6
View File
@@ -8,6 +8,7 @@ import (
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
@@ -129,9 +130,8 @@ var (
)
const (
defaultRegistryURL = "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Extension/main/registry.json"
cacheTTL = 30 * time.Minute
cacheFileName = "store_cache.json"
cacheTTL = 30 * time.Minute
cacheFileName = "store_cache.json"
)
func InitExtensionStore(cacheDir string) *ExtensionStore {
@@ -140,7 +140,7 @@ func InitExtensionStore(cacheDir string) *ExtensionStore {
if extensionStore == nil {
extensionStore = &ExtensionStore{
registryURL: defaultRegistryURL,
registryURL: "", // No default - user must provide a registry URL
cacheDir: cacheDir,
cacheTTL: cacheTTL,
}
@@ -149,6 +149,36 @@ func InitExtensionStore(cacheDir string) *ExtensionStore {
return extensionStore
}
// SetRegistryURL updates the registry URL and clears the in-memory cache
// so the next fetch will use the new URL. Disk cache is also cleared.
func (s *ExtensionStore) SetRegistryURL(registryURL string) {
s.cacheMu.Lock()
defer s.cacheMu.Unlock()
if s.registryURL == registryURL {
return
}
s.registryURL = registryURL
s.cache = nil
s.cacheTime = time.Time{}
// Clear disk cache since it's from a different registry
if s.cacheDir != "" {
cachePath := filepath.Join(s.cacheDir, cacheFileName)
os.Remove(cachePath)
}
LogInfo("ExtensionStore", "Registry URL updated to: %s", registryURL)
}
// GetRegistryURL returns the currently configured registry URL.
func (s *ExtensionStore) GetRegistryURL() string {
s.cacheMu.RLock()
defer s.cacheMu.RUnlock()
return s.registryURL
}
func GetExtensionStore() *ExtensionStore {
extensionStoreMu.Lock()
defer extensionStoreMu.Unlock()
@@ -206,6 +236,11 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error
s.cacheMu.Lock()
defer s.cacheMu.Unlock()
// Check if a registry URL has been configured
if s.registryURL == "" {
return nil, fmt.Errorf("no registry URL configured. Please add a repository URL first")
}
if !forceRefresh && s.cache != nil && time.Since(s.cacheTime) < s.cacheTTL {
LogDebug("ExtensionStore", "Using cached registry (%d extensions)", len(s.cache.Extensions))
return s.cache, nil
@@ -336,6 +371,81 @@ func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string)
return nil
}
// ResolveRegistryURL normalises a user-supplied URL into a direct registry.json URL.
//
// Accepted formats:
// - https://raw.githubusercontent.com/owner/repo/<branch>/registry.json → returned as-is
// - https://github.com/owner/repo (with optional trailing path / .git) → resolved via
// the GitHub API to discover the default branch, then converted to the raw URL
// - Any other HTTPS URL → returned as-is (assumed to be a direct link)
func ResolveRegistryURL(input string) (string, error) {
input = strings.TrimSpace(input)
if input == "" {
return "", fmt.Errorf("registry URL is empty")
}
// Already a fully-qualified raw URL keep it.
if strings.Contains(input, "raw.githubusercontent.com") {
return input, nil
}
// Try to match https://github.com/<owner>/<repo>[/...]
const ghPrefix = "https://github.com/"
if !strings.HasPrefix(input, ghPrefix) {
// Also accept http:// and upgrade silently.
const ghPrefixHTTP = "http://github.com/"
if strings.HasPrefix(input, ghPrefixHTTP) {
input = "https://github.com/" + input[len(ghPrefixHTTP):]
} else {
// Not a GitHub URL return as-is.
return input, nil
}
}
path := input[len(ghPrefix):]
parts := strings.SplitN(path, "/", 3) // owner, repo, [rest]
if len(parts) < 2 || parts[0] == "" || parts[1] == "" {
return "", fmt.Errorf("invalid GitHub URL: expected github.com/<owner>/<repo>")
}
owner := parts[0]
repo := strings.TrimSuffix(parts[1], ".git")
branch := resolveGitHubDefaultBranch(owner, repo)
resolved := fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s/registry.json", owner, repo, branch)
LogInfo("ExtensionStore", "Resolved %s → %s (branch: %s)", input, resolved, branch)
return resolved, nil
}
// resolveGitHubDefaultBranch calls the GitHub API to discover the repository's
// default branch. Falls back to "main" on any error.
func resolveGitHubDefaultBranch(owner, repo string) string {
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s", owner, repo)
client := NewHTTPClientWithTimeout(10 * time.Second)
resp, err := client.Get(apiURL)
if err != nil {
LogWarn("ExtensionStore", "GitHub API request failed for %s/%s: %v falling back to main", owner, repo, err)
return "main"
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
LogWarn("ExtensionStore", "GitHub API returned %d for %s/%s falling back to main", resp.StatusCode, owner, repo)
return "main"
}
var info struct {
DefaultBranch string `json:"default_branch"`
}
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil || info.DefaultBranch == "" {
LogWarn("ExtensionStore", "Could not parse default_branch for %s/%s falling back to main", owner, repo)
return "main"
}
return info.DefaultBranch
}
func requireHTTPSURL(rawURL string, context string) error {
if rawURL == "" {
return fmt.Errorf("%s URL is empty", context)
@@ -374,12 +484,10 @@ func (s *ExtensionStore) SearchExtensions(query string, category string) ([]Stor
queryLower := toLower(query)
for _, ext := range extensions {
// Filter by category
if category != "" && ext.Category != category {
continue
}
// Filter by query
if query != "" {
if !containsIgnoreCase(ext.Name, queryLower) &&
!containsIgnoreCase(ext.DisplayName, queryLower) &&
+5 -4
View File
@@ -31,7 +31,7 @@ func getRandomUserAgent() string {
const (
DefaultTimeout = 60 * time.Second
DownloadTimeout = 120 * time.Second
DownloadTimeout = 24 * time.Hour
SongLinkTimeout = 30 * time.Second
DefaultMaxRetries = 3
DefaultRetryDelay = 1 * time.Second
@@ -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 // Default wait time
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 // Default
return 0
}
func ReadResponseBody(resp *http.Response) ([]byte, error) {
+280 -38
View File
@@ -1,10 +1,12 @@
package gobackend
import (
"bufio"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
@@ -63,6 +65,7 @@ var supportedAudioFormats = map[string]bool{
".mp3": true,
".opus": true,
".ogg": true,
".cue": true,
}
type libraryAudioFileInfo struct {
@@ -70,6 +73,11 @@ type libraryAudioFileInfo struct {
modTime int64
}
type scannedCueFileInfo struct {
sheet *CueSheet
audioPath string
}
func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]libraryAudioFileInfo, error) {
var files []libraryAudioFileInfo
@@ -143,12 +151,7 @@ func ScanLibraryFolder(folderPath string) (string, error) {
return "[]", err
}
audioFiles := make([]string, 0, len(audioFileInfos))
for _, fileInfo := range audioFileInfos {
audioFiles = append(audioFiles, fileInfo.path)
}
totalFiles := len(audioFiles)
totalFiles := len(audioFileInfos)
libraryScanProgressMu.Lock()
libraryScanProgress.TotalFiles = totalFiles
libraryScanProgressMu.Unlock()
@@ -166,7 +169,31 @@ func ScanLibraryFolder(folderPath string) (string, error) {
scanTime := time.Now().UTC().Format(time.RFC3339)
errorCount := 0
for i, filePath := range audioFiles {
// Track audio files referenced by .cue sheets to avoid duplicates
cueReferencedAudioFiles := make(map[string]bool)
parsedCueFiles := make(map[string]scannedCueFileInfo)
// First pass: scan .cue files to collect referenced audio paths
for _, fileInfo := range audioFileInfos {
filePath := fileInfo.path
ext := strings.ToLower(filepath.Ext(filePath))
if ext == ".cue" {
sheet, err := ParseCueFile(filePath)
if err == nil && sheet.FileName != "" {
audioPath := ResolveCueAudioPath(filePath, sheet.FileName)
if audioPath != "" {
parsedCueFiles[filePath] = scannedCueFileInfo{
sheet: sheet,
audioPath: audioPath,
}
cueReferencedAudioFiles[audioPath] = true
}
}
}
}
for i, fileInfo := range audioFileInfos {
filePath := fileInfo.path
select {
case <-cancelCh:
return "[]", fmt.Errorf("scan cancelled")
@@ -179,7 +206,42 @@ func ScanLibraryFolder(folderPath string) (string, error) {
libraryScanProgress.ProgressPct = float64(i+1) / float64(totalFiles) * 100
libraryScanProgressMu.Unlock()
result, err := scanAudioFile(filePath, scanTime)
ext := strings.ToLower(filepath.Ext(filePath))
// Handle .cue files: produce multiple track results
if ext == ".cue" {
var cueResults []LibraryScanResult
cueInfo, ok := parsedCueFiles[filePath]
if ok {
cueResults, err = scanCueSheetForLibrary(
filePath,
cueInfo.sheet,
cueInfo.audioPath,
"",
fileInfo.modTime,
scanTime,
)
} else {
cueResults, err = ScanCueFileForLibrary(filePath, scanTime)
}
if err != nil {
errorCount++
GoLog("[LibraryScan] Error scanning cue %s: %v\n", filePath, err)
continue
}
results = append(results, cueResults...)
GoLog("[LibraryScan] CUE sheet %s: %d tracks\n", filepath.Base(filePath), len(cueResults))
continue
}
// Skip audio files that are referenced by a .cue sheet
// (they will be represented by the cue sheet's track entries instead)
if cueReferencedAudioFiles[filePath] {
GoLog("[LibraryScan] Skipping %s (referenced by .cue sheet)\n", filepath.Base(filePath))
continue
}
result, err := scanAudioFileWithKnownModTime(filePath, scanTime, fileInfo.modTime)
if err != nil {
errorCount++
GoLog("[LibraryScan] Error scanning %s: %v\n", filePath, err)
@@ -205,7 +267,15 @@ func ScanLibraryFolder(folderPath string) (string, error) {
}
func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) {
ext := strings.ToLower(filepath.Ext(filePath))
return scanAudioFileWithKnownModTimeAndDisplayName(filePath, "", scanTime, 0)
}
func scanAudioFileWithKnownModTime(filePath, scanTime string, knownModTime int64) (*LibraryScanResult, error) {
return scanAudioFileWithKnownModTimeAndDisplayName(filePath, "", scanTime, knownModTime)
}
func scanAudioFileWithKnownModTimeAndDisplayName(filePath, displayNameHint, scanTime string, knownModTime int64) (*LibraryScanResult, error) {
ext := resolveLibraryAudioExt(filePath, displayNameHint)
result := &LibraryScanResult{
ID: generateLibraryID(filePath),
@@ -214,15 +284,17 @@ func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) {
Format: strings.TrimPrefix(ext, "."),
}
if info, err := os.Stat(filePath); err == nil {
if knownModTime > 0 {
result.FileModTime = knownModTime
} else if info, err := os.Stat(filePath); err == nil {
result.FileModTime = info.ModTime().UnixMilli()
}
libraryCoverCacheMu.RLock()
coverCacheDir := libraryCoverCacheDir
libraryCoverCacheMu.RUnlock()
if coverCacheDir != "" && ext != ".m4a" {
coverPath, err := SaveCoverToCache(filePath, coverCacheDir)
if coverCacheDir != "" {
coverPath, err := SaveCoverToCacheWithHint(filePath, displayNameHint, coverCacheDir)
if err == nil && coverPath != "" {
result.CoverPath = coverPath
}
@@ -236,15 +308,31 @@ func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) {
case ".mp3":
return scanMP3File(filePath, result)
case ".opus", ".ogg":
return scanOggFile(filePath, result)
return scanOggFile(filePath, result, displayNameHint)
default:
return scanFromFilename(filePath, result)
return scanFromFilename(filePath, displayNameHint, result)
}
}
func applyDefaultLibraryMetadata(filePath string, result *LibraryScanResult) {
func resolveLibraryAudioExt(filePath, displayNameHint string) string {
ext := strings.ToLower(filepath.Ext(filePath))
if ext != "" {
return ext
}
return strings.ToLower(filepath.Ext(displayNameHint))
}
func libraryDisplayNameOrPath(filePath, displayNameHint string) string {
if displayNameHint != "" {
return displayNameHint
}
return filePath
}
func applyDefaultLibraryMetadata(filePath, displayNameHint string, result *LibraryScanResult) {
nameSource := libraryDisplayNameOrPath(filePath, displayNameHint)
if result.TrackName == "" {
result.TrackName = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
result.TrackName = strings.TrimSuffix(filepath.Base(nameSource), filepath.Ext(nameSource))
}
if result.ArtistName == "" {
result.ArtistName = "Unknown Artist"
@@ -257,7 +345,7 @@ func applyDefaultLibraryMetadata(filePath string, result *LibraryScanResult) {
func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
metadata, err := ReadMetadata(filePath)
if err != nil {
return scanFromFilename(filePath, result)
return scanFromFilename(filePath, "", result)
}
result.TrackName = metadata.Title
@@ -279,26 +367,43 @@ func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResul
}
}
applyDefaultLibraryMetadata(filePath, result)
applyDefaultLibraryMetadata(filePath, "", result)
return result, nil
}
func scanM4AFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
metadata, err := ReadM4ATags(filePath)
if err == nil && metadata != nil {
result.TrackName = metadata.Title
result.ArtistName = metadata.Artist
result.AlbumName = metadata.Album
result.AlbumArtist = metadata.AlbumArtist
result.ISRC = metadata.ISRC
result.TrackNumber = metadata.TrackNumber
result.DiscNumber = metadata.DiscNumber
result.ReleaseDate = metadata.Date
if result.ReleaseDate == "" {
result.ReleaseDate = metadata.Year
}
result.Genre = metadata.Genre
}
quality, err := GetM4AQuality(filePath)
if err == nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
}
return scanFromFilename(filePath, result)
applyDefaultLibraryMetadata(filePath, "", result)
return result, nil
}
func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
metadata, err := ReadID3Tags(filePath)
if err != nil {
GoLog("[LibraryScan] ID3 read error for %s: %v\n", filePath, err)
return scanFromFilename(filePath, result)
return scanFromFilename(filePath, "", result)
}
result.TrackName = metadata.Title
@@ -325,16 +430,16 @@ func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult
}
}
applyDefaultLibraryMetadata(filePath, result)
applyDefaultLibraryMetadata(filePath, "", result)
return result, nil
}
func scanOggFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
func scanOggFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
metadata, err := ReadOggVorbisComments(filePath)
if err != nil {
GoLog("[LibraryScan] Ogg/Opus read error for %s: %v\n", filePath, err)
return scanFromFilename(filePath, result)
return scanFromFilename(filePath, displayNameHint, result)
}
result.TrackName = metadata.Title
@@ -357,13 +462,14 @@ func scanOggFile(filePath string, result *LibraryScanResult) (*LibraryScanResult
}
}
applyDefaultLibraryMetadata(filePath, result)
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
return result, nil
}
func scanFromFilename(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
filename := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
func scanFromFilename(filePath, displayNameHint string, result *LibraryScanResult) (*LibraryScanResult, error) {
nameSource := libraryDisplayNameOrPath(filePath, displayNameHint)
filename := strings.TrimSuffix(filepath.Base(nameSource), filepath.Ext(nameSource))
parts := strings.SplitN(filename, " - ", 2)
if len(parts) == 2 {
@@ -386,7 +492,7 @@ func scanFromFilename(filePath string, result *LibraryScanResult) (*LibraryScanR
dir := filepath.Dir(filePath)
result.AlbumName = filepath.Base(dir)
if result.AlbumName == "." || result.AlbumName == "" {
if result.AlbumName == "." || result.AlbumName == "" || result.AlbumName == "fd" || result.AlbumName == "self" {
result.AlbumName = "Unknown Album"
}
@@ -433,8 +539,12 @@ func CancelLibraryScan() {
}
func ReadAudioMetadata(filePath string) (string, error) {
return ReadAudioMetadataWithDisplayName(filePath, "")
}
func ReadAudioMetadataWithDisplayName(filePath, displayNameHint string) (string, error) {
scanTime := time.Now().UTC().Format(time.RFC3339)
result, err := scanAudioFile(filePath, scanTime)
result, err := scanAudioFileWithKnownModTimeAndDisplayName(filePath, displayNameHint, scanTime, 0)
if err != nil {
return "", err
}
@@ -450,7 +560,43 @@ func ReadAudioMetadata(filePath string) (string, error) {
// ScanLibraryFolderIncremental performs an incremental scan of the library folder
// existingFilesJSON is a JSON object mapping filePath -> modTime (unix millis)
// Only files that are new or have changed modification time will be scanned
func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, error) {
func loadExistingFilesSnapshot(snapshotPath string) (map[string]int64, error) {
existingFiles := make(map[string]int64)
if snapshotPath == "" {
return existingFiles, nil
}
file, err := os.Open(snapshotPath)
if err != nil {
return nil, err
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
parts := strings.SplitN(line, "\t", 2)
if len(parts) != 2 {
continue
}
modTime, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil {
continue
}
existingFiles[parts[1]] = modTime
}
if err := scanner.Err(); err != nil {
return nil, err
}
return existingFiles, nil
}
func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFiles map[string]int64) (string, error) {
if folderPath == "" {
return "{}", fmt.Errorf("folder path is empty")
}
@@ -463,13 +609,6 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
return "{}", fmt.Errorf("path is not a folder: %s", folderPath)
}
existingFiles := make(map[string]int64)
if existingFilesJSON != "" && existingFilesJSON != "{}" {
if err := json.Unmarshal([]byte(existingFilesJSON), &existingFiles); err != nil {
GoLog("[LibraryScan] Warning: failed to parse existing files JSON: %v\n", err)
}
}
GoLog("[LibraryScan] Incremental scan starting, %d existing files in database\n", len(existingFiles))
libraryScanProgressMu.Lock()
@@ -501,10 +640,31 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
// Find files to scan (new or modified)
var filesToScan []libraryAudioFileInfo
skippedCount := 0
existingCueTrackModTimes := make(map[string]int64)
for existingPath, modTime := range existingFiles {
if idx := strings.LastIndex(existingPath, "#track"); idx > 0 {
baseCuePath := existingPath[:idx]
if _, exists := existingCueTrackModTimes[baseCuePath]; !exists {
existingCueTrackModTimes[baseCuePath] = modTime
}
}
}
for _, f := range currentFiles {
existingModTime, exists := existingFiles[f.path]
if !exists {
// For .cue files, also check if any virtual path entries exist
if strings.ToLower(filepath.Ext(f.path)) == ".cue" {
if cueTrackModTime, hasCueTracks := existingCueTrackModTimes[f.path]; hasCueTracks {
// CUE file exists in DB via virtual paths; check if modTime changed
if f.modTime == cueTrackModTime {
skippedCount++
} else {
filesToScan = append(filesToScan, f)
}
continue
}
}
filesToScan = append(filesToScan, f)
} else if f.modTime != existingModTime {
filesToScan = append(filesToScan, f)
@@ -515,7 +675,16 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
var deletedPaths []string
for existingPath := range existingFiles {
if !currentPathSet[existingPath] {
// For CUE virtual paths (e.g. "/path/album.cue#track01"),
// check if the base .cue file still exists on disk
if idx := strings.LastIndex(existingPath, "#track"); idx > 0 {
baseCuePath := existingPath[:idx]
if currentPathSet[baseCuePath] {
continue // Base .cue file still exists, not deleted
}
// Base CUE file is gone, mark virtual path as deleted
deletedPaths = append(deletedPaths, existingPath)
} else if !currentPathSet[existingPath] {
deletedPaths = append(deletedPaths, existingPath)
}
}
@@ -544,6 +713,26 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
scanTime := time.Now().UTC().Format(time.RFC3339)
errorCount := 0
// Track audio files referenced by .cue sheets to avoid duplicates (incremental)
cueReferencedAudioFilesInc := make(map[string]bool)
parsedCueFiles := make(map[string]scannedCueFileInfo)
for _, f := range filesToScan {
ext := strings.ToLower(filepath.Ext(f.path))
if ext == ".cue" {
sheet, err := ParseCueFile(f.path)
if err == nil && sheet.FileName != "" {
audioPath := ResolveCueAudioPath(f.path, sheet.FileName)
if audioPath != "" {
parsedCueFiles[f.path] = scannedCueFileInfo{
sheet: sheet,
audioPath: audioPath,
}
cueReferencedAudioFilesInc[audioPath] = true
}
}
}
}
for i, f := range filesToScan {
select {
case <-cancelCh:
@@ -557,7 +746,39 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
libraryScanProgress.ProgressPct = float64(skippedCount+i+1) / float64(totalFiles) * 100
libraryScanProgressMu.Unlock()
result, err := scanAudioFile(f.path, scanTime)
ext := strings.ToLower(filepath.Ext(f.path))
// Handle .cue files: produce multiple track results
if ext == ".cue" {
var cueResults []LibraryScanResult
cueInfo, ok := parsedCueFiles[f.path]
if ok {
cueResults, err = scanCueSheetForLibrary(
f.path,
cueInfo.sheet,
cueInfo.audioPath,
"",
f.modTime,
scanTime,
)
} else {
cueResults, err = ScanCueFileForLibrary(f.path, scanTime)
}
if err != nil {
errorCount++
GoLog("[LibraryScan] Error scanning cue %s: %v\n", f.path, err)
continue
}
results = append(results, cueResults...)
continue
}
// Skip audio files referenced by .cue sheets
if cueReferencedAudioFilesInc[f.path] {
continue
}
result, err := scanAudioFileWithKnownModTime(f.path, scanTime, f.modTime)
if err != nil {
errorCount++
GoLog("[LibraryScan] Error scanning %s: %v\n", f.path, err)
@@ -591,3 +812,24 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
return string(jsonBytes), nil
}
// ScanLibraryFolderIncremental performs an incremental scan of the library folder
// existingFilesJSON is a JSON object mapping filePath -> modTime (unix millis)
// Only files that are new or have changed modification time will be scanned
func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, error) {
existingFiles := make(map[string]int64)
if existingFilesJSON != "" && existingFilesJSON != "{}" {
if err := json.Unmarshal([]byte(existingFilesJSON), &existingFiles); err != nil {
GoLog("[LibraryScan] Warning: failed to parse existing files JSON: %v\n", err)
}
}
return scanLibraryFolderIncrementalWithExistingFiles(folderPath, existingFiles)
}
func ScanLibraryFolderIncrementalFromSnapshot(folderPath, snapshotPath string) (string, error) {
existingFiles, err := loadExistingFilesSnapshot(snapshotPath)
if err != nil {
return "{}", fmt.Errorf("failed to load incremental snapshot: %w", err)
}
return scanLibraryFolderIncrementalWithExistingFiles(folderPath, existingFiles)
}
+107 -64
View File
@@ -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
View File
@@ -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
}
+92 -89
View File
@@ -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")
+4 -11
View File
@@ -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()
+39 -95
View File
@@ -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)
+320 -6
View File
@@ -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,299 @@ func ExtractLyrics(filePath string) (string, error) {
return extractLyricsFromSidecarLRC(filePath)
}
func ReadM4ATags(filePath string) (*AudioMetadata, 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
}
ilst, err := findM4AIlstAtom(f, fi.Size())
if err != nil {
return nil, err
}
metadata := &AudioMetadata{}
start := ilst.offset + ilst.headerSize
end := ilst.offset + ilst.size
for pos := start; pos+8 <= end; {
header, err := readAtomHeaderAt(f, pos, fi.Size())
if err != nil {
return nil, err
}
if header.size == 0 {
header.size = end - pos
}
if header.size < header.headerSize {
return nil, fmt.Errorf("invalid atom size for %s", header.typ)
}
switch header.typ {
case "\xa9nam":
metadata.Title, _ = readM4ATextValue(f, header, fi.Size())
case "\xa9ART":
metadata.Artist, _ = readM4ATextValue(f, header, fi.Size())
case "\xa9alb":
metadata.Album, _ = readM4ATextValue(f, header, fi.Size())
case "aART":
metadata.AlbumArtist, _ = readM4ATextValue(f, header, fi.Size())
case "\xa9day":
metadata.Date, _ = readM4ATextValue(f, header, fi.Size())
metadata.Year = metadata.Date
case "\xa9gen":
metadata.Genre, _ = readM4ATextValue(f, header, fi.Size())
case "\xa9wrt":
metadata.Composer, _ = readM4ATextValue(f, header, fi.Size())
case "\xa9cmt":
metadata.Comment, _ = readM4ATextValue(f, header, fi.Size())
case "cprt":
metadata.Copyright, _ = readM4ATextValue(f, header, fi.Size())
case "\xa9lyr":
metadata.Lyrics, _ = readM4ATextValue(f, header, fi.Size())
case "trkn":
metadata.TrackNumber, _ = readM4AIndexValue(f, header, fi.Size())
case "disk":
metadata.DiscNumber, _ = readM4AIndexValue(f, header, fi.Size())
case "----":
name, value, freeformErr := readM4AFreeformValue(f, header, fi.Size())
if freeformErr == nil {
switch strings.ToUpper(strings.TrimSpace(name)) {
case "ISRC":
metadata.ISRC = value
case "LABEL", "ORGANIZATION":
metadata.Label = value
case "COMMENT":
if metadata.Comment == "" {
metadata.Comment = value
}
case "COMPOSER":
if metadata.Composer == "" {
metadata.Composer = value
}
case "COPYRIGHT":
if metadata.Copyright == "" {
metadata.Copyright = value
}
case "LYRICS", "UNSYNCEDLYRICS":
if metadata.Lyrics == "" {
metadata.Lyrics = value
}
}
}
}
pos += header.size
}
if metadata.Title == "" &&
metadata.Artist == "" &&
metadata.Album == "" &&
metadata.AlbumArtist == "" &&
metadata.Lyrics == "" &&
metadata.TrackNumber == 0 &&
metadata.DiscNumber == 0 {
return nil, fmt.Errorf("no M4A tags found")
}
return metadata, nil
}
func extractLyricsFromM4A(filePath string) (string, error) {
metadata, err := ReadM4ATags(filePath)
if err != nil {
return "", err
}
if metadata == nil || strings.TrimSpace(metadata.Lyrics) == "" {
return "", fmt.Errorf("no lyrics found in file")
}
return metadata.Lyrics, 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()
ilst, err := findM4AIlstAtom(f, fileSize)
if err != nil {
return nil, err
}
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
}
// findM4AIlstAtom locates the ilst atom that holds all iTunes-style tags.
// It tries two common layouts:
// 1. moov > udta > meta > ilst (iTunes, FFmpeg default)
// 2. moov > meta > ilst (some encoders omit the udta wrapper)
func findM4AIlstAtom(f *os.File, fileSize int64) (atomHeader, error) {
moov, found, err := findAtomInRange(f, 0, fileSize, "moov", fileSize)
if err != nil || !found {
return atomHeader{}, fmt.Errorf("moov not found")
}
moovBodyStart := moov.offset + moov.headerSize
moovBodySize := moov.size - moov.headerSize
// Path 1: moov > udta > meta > ilst
if udta, ok, _ := findAtomInRange(f, moovBodyStart, moovBodySize, "udta", fileSize); ok {
udtaBodyStart := udta.offset + udta.headerSize
udtaBodySize := udta.size - udta.headerSize
if meta, ok2, _ := findAtomInRange(f, udtaBodyStart, udtaBodySize, "meta", fileSize); ok2 {
metaBodyStart := meta.offset + meta.headerSize + 4
metaBodySize := meta.size - meta.headerSize - 4
if ilst, ok3, _ := findAtomInRange(f, metaBodyStart, metaBodySize, "ilst", fileSize); ok3 {
return ilst, nil
}
}
}
// Path 2: moov > meta > ilst (no udta wrapper)
if meta, ok, _ := findAtomInRange(f, moovBodyStart, moovBodySize, "meta", fileSize); ok {
metaBodyStart := meta.offset + meta.headerSize + 4
metaBodySize := meta.size - meta.headerSize - 4
if ilst, ok2, _ := findAtomInRange(f, metaBodyStart, metaBodySize, "ilst", fileSize); ok2 {
return ilst, nil
}
}
return atomHeader{}, fmt.Errorf("ilst not found (tried moov>udta>meta>ilst and moov>meta>ilst)")
}
func readM4ADataAtomPayload(f *os.File, dataAtom atomHeader) ([]byte, error) {
payloadStart := dataAtom.offset + dataAtom.headerSize + 8
payloadLen := dataAtom.size - dataAtom.headerSize - 8
if payloadLen <= 0 {
return nil, fmt.Errorf("empty data atom in %s", dataAtom.typ)
}
buf := make([]byte, payloadLen)
if _, err := f.ReadAt(buf, payloadStart); err != nil {
return nil, err
}
return buf, nil
}
func readM4ADataPayload(f *os.File, parent atomHeader, fileSize int64) ([]byte, error) {
dataStart := parent.offset + parent.headerSize
dataSize := parent.size - parent.headerSize
dataAtom, found, err := findAtomInRange(f, dataStart, dataSize, "data", fileSize)
if err != nil || !found {
return nil, fmt.Errorf("data atom not found in %s", parent.typ)
}
return readM4ADataAtomPayload(f, dataAtom)
}
func readM4ATextValue(f *os.File, parent atomHeader, fileSize int64) (string, error) {
payload, err := readM4ADataPayload(f, parent, fileSize)
if err != nil {
return "", err
}
return strings.TrimSpace(strings.TrimRight(string(payload), "\x00")), nil
}
func readM4AIndexValue(f *os.File, parent atomHeader, fileSize int64) (int, error) {
payload, err := readM4ADataPayload(f, parent, fileSize)
if err != nil {
return 0, err
}
if len(payload) < 4 {
return 0, fmt.Errorf("index payload too short in %s", parent.typ)
}
return int(binary.BigEndian.Uint16(payload[2:4])), nil
}
func readM4AFreeformValue(f *os.File, parent atomHeader, fileSize int64) (string, string, error) {
start := parent.offset + parent.headerSize
end := parent.offset + parent.size
var nameValue string
var dataValue string
for pos := start; pos+8 <= end; {
header, err := readAtomHeaderAt(f, pos, fileSize)
if err != nil {
return "", "", err
}
if header.size == 0 {
header.size = end - pos
}
if header.size < header.headerSize {
return "", "", fmt.Errorf("invalid atom size for %s", header.typ)
}
switch header.typ {
case "mean":
// Domain qualifier (e.g. "com.apple.iTunes") — not needed, skip.
case "name":
// The "name" atom payload is: 4-byte version/flags, then raw UTF-8 text.
// It does NOT contain a nested "data" atom, so read the payload directly.
payloadStart := header.offset + header.headerSize + 4
payloadLen := header.size - header.headerSize - 4
if payloadLen > 0 {
buf := make([]byte, payloadLen)
if _, readErr := f.ReadAt(buf, payloadStart); readErr == nil {
nameValue = strings.TrimSpace(strings.TrimRight(string(buf), "\x00"))
}
}
case "data":
payload, payloadErr := readM4ADataAtomPayload(f, header)
if payloadErr == nil {
dataValue = strings.TrimSpace(strings.TrimRight(string(payload), "\x00"))
}
}
pos += header.size
}
if nameValue == "" || dataValue == "" {
return "", "", fmt.Errorf("freeform M4A tag incomplete")
}
return nameValue, dataValue, nil
}
func extractLyricsFromSidecarLRC(filePath string) (string, error) {
ext := filepath.Ext(filePath)
base := strings.TrimSuffix(filePath, ext)
@@ -743,15 +1044,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 +1188,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
+31 -4
View File
@@ -34,10 +34,16 @@ var (
downloadDir string
downloadDirMu sync.RWMutex
multiProgress = MultiProgress{Items: make(map[string]*ItemProgress)}
multiMu sync.RWMutex
multiProgress = MultiProgress{Items: make(map[string]*ItemProgress)}
multiMu sync.RWMutex
multiProgressDirty = true
cachedMultiProgress = "{\"items\":{}}"
)
func markMultiProgressDirtyLocked() {
multiProgressDirty = true
}
func getProgress() DownloadProgress {
multiMu.RLock()
defer multiMu.RUnlock()
@@ -58,13 +64,25 @@ func getProgress() DownloadProgress {
func GetMultiProgress() string {
multiMu.RLock()
defer multiMu.RUnlock()
if !multiProgressDirty {
cached := cachedMultiProgress
multiMu.RUnlock()
return cached
}
multiMu.RUnlock()
multiMu.Lock()
defer multiMu.Unlock()
if !multiProgressDirty {
return cachedMultiProgress
}
jsonBytes, err := json.Marshal(multiProgress)
if err != nil {
return "{\"items\":{}}"
}
return string(jsonBytes)
cachedMultiProgress = string(jsonBytes)
multiProgressDirty = false
return cachedMultiProgress
}
func GetItemProgress(itemID string) string {
@@ -90,6 +108,7 @@ func StartItemProgress(itemID string) {
IsDownloading: true,
Status: "downloading",
}
markMultiProgressDirtyLocked()
}
func SetItemBytesTotal(itemID string, total int64) {
@@ -98,6 +117,7 @@ func SetItemBytesTotal(itemID string, total int64) {
if item, ok := multiProgress.Items[itemID]; ok {
item.BytesTotal = total
markMultiProgressDirtyLocked()
}
}
@@ -110,6 +130,7 @@ func SetItemBytesReceived(itemID string, received int64) {
if item.BytesTotal > 0 {
item.Progress = float64(received) / float64(item.BytesTotal)
}
markMultiProgressDirtyLocked()
}
}
@@ -123,6 +144,7 @@ func SetItemBytesReceivedWithSpeed(itemID string, received int64, speedMBps floa
if item.BytesTotal > 0 {
item.Progress = float64(received) / float64(item.BytesTotal)
}
markMultiProgressDirtyLocked()
}
}
@@ -134,6 +156,7 @@ func CompleteItemProgress(itemID string) {
item.Progress = 1.0
item.IsDownloading = false
item.Status = "completed"
markMultiProgressDirtyLocked()
}
}
@@ -149,6 +172,7 @@ func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal
if bytesTotal > 0 {
item.BytesTotal = bytesTotal
}
markMultiProgressDirtyLocked()
}
}
@@ -159,6 +183,7 @@ func SetItemFinalizing(itemID string) {
if item, ok := multiProgress.Items[itemID]; ok {
item.Progress = 1.0
item.Status = "finalizing"
markMultiProgressDirtyLocked()
}
}
@@ -167,6 +192,7 @@ func RemoveItemProgress(itemID string) {
defer multiMu.Unlock()
delete(multiProgress.Items, itemID)
markMultiProgressDirtyLocked()
}
func ClearAllItemProgress() {
@@ -174,6 +200,7 @@ func ClearAllItemProgress() {
defer multiMu.Unlock()
multiProgress.Items = make(map[string]*ItemProgress)
markMultiProgressDirtyLocked()
}
func setDownloadDir(path string) error {
+899 -47
View File
File diff suppressed because it is too large Load Diff
+306 -3
View File
@@ -1,6 +1,98 @@
package gobackend
import "testing"
import (
"encoding/json"
"testing"
)
func TestParseQobuzURL(t *testing.T) {
tests := []struct {
name string
input string
wantType string
wantID string
expectErr bool
}{
{
name: "store album url",
input: "https://www.qobuz.com/us-en/album/harry-styles-harry-styles/0886446451985",
wantType: "album",
wantID: "0886446451985",
},
{
name: "store playlist url",
input: "https://www.qobuz.com/us-en/playlists/new-releases/2049430",
wantType: "playlist",
wantID: "2049430",
},
{
name: "store artist url",
input: "https://www.qobuz.com/us-en/interpreter/harry-styles/729886",
wantType: "artist",
wantID: "729886",
},
{
name: "play track url",
input: "https://play.qobuz.com/track/40681594",
wantType: "track",
wantID: "40681594",
},
{
name: "custom scheme playlist url",
input: "qobuzapp://playlist/2049430",
wantType: "playlist",
wantID: "2049430",
},
{
name: "unsupported url",
input: "https://example.com/not-qobuz",
expectErr: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
gotType, gotID, err := parseQobuzURL(test.input)
if test.expectErr {
if err == nil {
t.Fatalf("expected error, got none")
}
return
}
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if gotType != test.wantType || gotID != test.wantID {
t.Fatalf("parseQobuzURL(%q) = (%q, %q), want (%q, %q)", test.input, gotType, gotID, test.wantType, test.wantID)
}
})
}
}
func TestExtractQobuzArtistAlbumIDs(t *testing.T) {
body := []byte(`
<div class="product__item">
<button data-itemtype="album" data-itemId="yrpbt0lwm3g0y"></button>
</div>
<div class="product__item">
<button data-itemtype="album" data-itemId="yrpbt0lwm3g0y"></button>
</div>
<div class="product__item">
<button data-itemtype="album" data-itemId="0886446451985"></button>
</div>
`)
matches := qobuzArtistAlbumIDRegex.FindAllSubmatch(body, -1)
if len(matches) != 3 {
t.Fatalf("expected 3 regex matches, got %d", len(matches))
}
if string(matches[0][1]) != "yrpbt0lwm3g0y" {
t.Fatalf("unexpected first album id: %q", matches[0][1])
}
if string(matches[2][1]) != "0886446451985" {
t.Fatalf("unexpected last album id: %q", matches[2][1])
}
}
func TestExtractQobuzDownloadURLFromBody(t *testing.T) {
t.Run("reads top-level download_url and quality metadata", func(t *testing.T) {
@@ -106,16 +198,56 @@ 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>
<button data-itemtype="album" data-itemId="0886446451985"></button>
<button data-itemtype="album" data-itemId="pvv406bth40ya"></button>
`)
got := extractQobuzAlbumIDsFromArtistHTML(body)
if len(got) != 2 {
t.Fatalf("expected 2 unique album IDs, got %d (%v)", len(got), got)
}
if got[0] != "0886446451985" || got[1] != "pvv406bth40ya" {
t.Fatalf("unexpected album IDs: %v", got)
}
}
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 {
@@ -133,3 +265,174 @@ func TestQobuzAvailableProviders(t *testing.T) {
t.Fatalf("missing providers: %v", want)
}
}
func testQobuzTrack(id int64, title, artist string, duration int) *QobuzTrack {
track := &QobuzTrack{
ID: id,
Title: title,
Duration: duration,
}
track.Performer.Name = artist
return track
}
func TestResolveQobuzTrackForRequestRejectsSongLinkMismatch(t *testing.T) {
origGetTrackByID := qobuzGetTrackByIDFunc
origSearchISRC := qobuzSearchTrackByISRCWithDurationFunc
origSearchMetadata := qobuzSearchTrackByMetadataWithDurationFunc
origSongLinkCheck := songLinkCheckTrackAvailabilityFunc
t.Cleanup(func() {
qobuzGetTrackByIDFunc = origGetTrackByID
qobuzSearchTrackByISRCWithDurationFunc = origSearchISRC
qobuzSearchTrackByMetadataWithDurationFunc = origSearchMetadata
songLinkCheckTrackAvailabilityFunc = origSongLinkCheck
GetTrackIDCache().Clear()
})
GetTrackIDCache().Clear()
qobuzGetTrackByIDFunc = func(_ *QobuzDownloader, trackID int64) (*QobuzTrack, error) {
if trackID != 111 {
t.Fatalf("unexpected track ID lookup: %d", trackID)
}
return testQobuzTrack(111, "Aperture", "Harry Styles", 180), nil
}
qobuzSearchTrackByISRCWithDurationFunc = func(_ *QobuzDownloader, isrc string, expectedDurationSec int) (*QobuzTrack, error) {
if isrc != "TESTISRC1" {
t.Fatalf("unexpected ISRC lookup: %q", isrc)
}
if expectedDurationSec != 180 {
t.Fatalf("unexpected duration: %d", expectedDurationSec)
}
return testQobuzTrack(222, "Taste Back", "Harry Styles", 180), nil
}
qobuzSearchTrackByMetadataWithDurationFunc = func(_ *QobuzDownloader, _, _ string, _ int) (*QobuzTrack, error) {
t.Fatal("metadata fallback should not run when ISRC fallback succeeds")
return nil, nil
}
songLinkCheckTrackAvailabilityFunc = func(_ *SongLinkClient, spotifyTrackID string, isrc string) (*TrackAvailability, error) {
if spotifyTrackID != "spotify-track-id" {
t.Fatalf("unexpected spotify ID: %q", spotifyTrackID)
}
if isrc != "TESTISRC1" {
t.Fatalf("unexpected SongLink ISRC: %q", isrc)
}
return &TrackAvailability{QobuzID: "111"}, nil
}
req := DownloadRequest{
ISRC: "TESTISRC1",
SpotifyID: "spotify-track-id",
TrackName: "Taste Back",
ArtistName: "Harry Styles",
DurationMS: 180000,
}
track, err := resolveQobuzTrackForRequest(req, &QobuzDownloader{}, "Test")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if track == nil || track.ID != 222 || track.Title != "Taste Back" {
t.Fatalf("unexpected resolved track: %+v", track)
}
cached := GetTrackIDCache().Get(req.ISRC)
if cached == nil || cached.QobuzTrackID != 222 {
t.Fatalf("expected validated fallback track to be cached, got %+v", cached)
}
}
func TestResolveQobuzTrackForRequestRejectsOdesliMismatch(t *testing.T) {
origGetTrackByID := qobuzGetTrackByIDFunc
origSearchISRC := qobuzSearchTrackByISRCWithDurationFunc
origSearchMetadata := qobuzSearchTrackByMetadataWithDurationFunc
origSongLinkCheck := songLinkCheckTrackAvailabilityFunc
t.Cleanup(func() {
qobuzGetTrackByIDFunc = origGetTrackByID
qobuzSearchTrackByISRCWithDurationFunc = origSearchISRC
qobuzSearchTrackByMetadataWithDurationFunc = origSearchMetadata
songLinkCheckTrackAvailabilityFunc = origSongLinkCheck
})
qobuzGetTrackByIDFunc = func(_ *QobuzDownloader, trackID int64) (*QobuzTrack, error) {
if trackID != 333 {
t.Fatalf("unexpected track ID lookup: %d", trackID)
}
return testQobuzTrack(333, "American Girls", "Harry Styles", 181), nil
}
qobuzSearchTrackByISRCWithDurationFunc = func(_ *QobuzDownloader, _ string, _ int) (*QobuzTrack, error) {
t.Fatal("ISRC fallback should not run without an ISRC")
return nil, nil
}
qobuzSearchTrackByMetadataWithDurationFunc = func(_ *QobuzDownloader, trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) {
if trackName != "Taste Back" || artistName != "Harry Styles" || expectedDurationSec != 181 {
t.Fatalf("unexpected metadata fallback arguments: %q / %q / %d", trackName, artistName, expectedDurationSec)
}
return testQobuzTrack(444, "Taste Back", "Harry Styles", 181), nil
}
songLinkCheckTrackAvailabilityFunc = func(_ *SongLinkClient, _, _ string) (*TrackAvailability, error) {
t.Fatal("SongLink should not run when Odesli QobuzID is provided")
return nil, nil
}
req := DownloadRequest{
QobuzID: "333",
TrackName: "Taste Back",
ArtistName: "Harry Styles",
DurationMS: 181000,
}
track, err := resolveQobuzTrackForRequest(req, &QobuzDownloader{}, "Test")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if track == nil || track.ID != 444 || track.Title != "Taste Back" {
t.Fatalf("unexpected resolved track: %+v", track)
}
}
func TestResolveQobuzTrackForRequestUsesPrefixedQobuzIDWithoutSongLink(t *testing.T) {
origGetTrackByID := qobuzGetTrackByIDFunc
origSearchISRC := qobuzSearchTrackByISRCWithDurationFunc
origSearchMetadata := qobuzSearchTrackByMetadataWithDurationFunc
origSongLinkCheck := songLinkCheckTrackAvailabilityFunc
t.Cleanup(func() {
qobuzGetTrackByIDFunc = origGetTrackByID
qobuzSearchTrackByISRCWithDurationFunc = origSearchISRC
qobuzSearchTrackByMetadataWithDurationFunc = origSearchMetadata
songLinkCheckTrackAvailabilityFunc = origSongLinkCheck
})
qobuzGetTrackByIDFunc = func(_ *QobuzDownloader, trackID int64) (*QobuzTrack, error) {
if trackID != 40681594 {
t.Fatalf("unexpected track ID lookup: %d", trackID)
}
return testQobuzTrack(40681594, "Sign of the Times", "Harry Styles", 341), nil
}
qobuzSearchTrackByISRCWithDurationFunc = func(_ *QobuzDownloader, _ string, _ int) (*QobuzTrack, error) {
t.Fatal("ISRC fallback should not run when request qobuz id succeeds")
return nil, nil
}
qobuzSearchTrackByMetadataWithDurationFunc = func(_ *QobuzDownloader, _, _ string, _ int) (*QobuzTrack, error) {
t.Fatal("metadata fallback should not run when request qobuz id succeeds")
return nil, nil
}
songLinkCheckTrackAvailabilityFunc = func(_ *SongLinkClient, _, _ string) (*TrackAvailability, error) {
t.Fatal("SongLink should not run when request qobuz id is provided")
return nil, nil
}
req := DownloadRequest{
QobuzID: "qobuz:40681594",
TrackName: "Sign of the Times",
ArtistName: "Harry Styles",
DurationMS: 341000,
}
track, err := resolveQobuzTrackForRequest(req, &QobuzDownloader{}, "Test")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if track == nil || track.ID != 40681594 {
t.Fatalf("unexpected resolved track: %+v", track)
}
}
-80
View File
@@ -1,80 +0,0 @@
package gobackend
import (
"path/filepath"
"strings"
"testing"
)
func TestSanitizeSensitiveLogText(t *testing.T) {
input := "access_token=abc123 Authorization:Bearer xyz456 https://api.example.com/cb?refresh_token=zzz"
redacted := sanitizeSensitiveLogText(input)
if strings.Contains(redacted, "abc123") || strings.Contains(redacted, "xyz456") || strings.Contains(redacted, "zzz") {
t.Fatalf("expected sensitive values to be redacted, got: %s", redacted)
}
if !strings.Contains(redacted, "[REDACTED]") {
t.Fatalf("expected redaction marker in output, got: %s", redacted)
}
}
func TestValidateExtensionAuthURL(t *testing.T) {
if err := validateExtensionAuthURL("https://accounts.example.com/oauth/authorize"); err != nil {
t.Fatalf("expected valid auth URL, got error: %v", err)
}
blocked := []string{
"http://accounts.example.com/oauth/authorize",
"https://user:pass@accounts.example.com/oauth/authorize",
"https://localhost/oauth/authorize",
}
for _, rawURL := range blocked {
if err := validateExtensionAuthURL(rawURL); err == nil {
t.Fatalf("expected URL to be blocked: %s", rawURL)
}
}
}
func TestValidateDomainRejectsEmbeddedCredentials(t *testing.T) {
ext := &LoadedExtension{
ID: "test-ext",
Manifest: &ExtensionManifest{
Name: "test-ext",
Permissions: ExtensionPermissions{
Network: []string{"api.example.com"},
},
},
DataDir: t.TempDir(),
}
runtime := NewExtensionRuntime(ext)
if err := runtime.validateDomain("https://user:pass@api.example.com/resource"); err == nil {
t.Fatal("expected embedded URL credentials to be rejected")
}
}
func TestBuildStoreExtensionDestPath(t *testing.T) {
baseDir := t.TempDir()
destPath, err := buildStoreExtensionDestPath(baseDir, "../evil/name")
if err != nil {
t.Fatalf("expected sanitized path to be generated, got error: %v", err)
}
if !isPathWithinBase(baseDir, destPath) {
t.Fatalf("expected destination path to remain under base dir: %s", destPath)
}
baseName := filepath.Base(destPath)
if strings.Contains(baseName, "/") || strings.Contains(baseName, `\`) {
t.Fatalf("expected filename to be sanitized, got: %s", baseName)
}
if !strings.HasSuffix(baseName, ".spotiflac-ext") {
t.Fatalf("expected .spotiflac-ext suffix, got: %s", baseName)
}
if _, err := buildStoreExtensionDestPath(baseDir, " "); err == nil {
t.Fatal("expected empty extension id to be rejected")
}
}
+144 -43
View File
@@ -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)
+127
View File
@@ -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")
}
}
+1 -1
View File
@@ -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.
+5 -29
View File
@@ -9,7 +9,6 @@ import (
"math/rand"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
@@ -64,45 +63,20 @@ var (
credentialsMu sync.RWMutex
)
var ErrNoSpotifyCredentials = errors.New("Spotify credentials not configured. Please set your own Client ID and Secret in Settings, or use Deezer as metadata source (free, no credentials required)")
var ErrNoSpotifyCredentials = errors.New("built-in Spotify API metadata provider has been removed; use Deezer or the spotify-web extension instead")
func SetSpotifyCredentials(clientID, clientSecret string) {
credentialsMu.Lock()
defer credentialsMu.Unlock()
customClientID = clientID
customClientSecret = clientSecret
customClientID = ""
customClientSecret = ""
}
func HasSpotifyCredentials() bool {
credentialsMu.RLock()
defer credentialsMu.RUnlock()
if customClientID != "" && customClientSecret != "" {
return true
}
if os.Getenv("SPOTIFY_CLIENT_ID") != "" && os.Getenv("SPOTIFY_CLIENT_SECRET") != "" {
return true
}
return false
}
func getCredentials() (string, string, error) {
credentialsMu.RLock()
defer credentialsMu.RUnlock()
if customClientID != "" && customClientSecret != "" {
return customClientID, customClientSecret, nil
}
clientID := os.Getenv("SPOTIFY_CLIENT_ID")
clientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET")
if clientID != "" && clientSecret != "" {
return clientID, clientSecret, nil
}
return "", "", ErrNoSpotifyCredentials
}
@@ -183,6 +157,8 @@ type AlbumResponsePayload struct {
}
type PlaylistInfoMetadata struct {
Name string `json:"name,omitempty"`
Images string `json:"images,omitempty"`
Tracks struct {
Total int `json:"total"`
} `json:"tracks"`
+1043 -29
View File
File diff suppressed because it is too large Load Diff
+222
View File
@@ -0,0 +1,222 @@
package gobackend
import "testing"
func TestParseTidalURL(t *testing.T) {
tests := []struct {
name string
input string
wantType string
wantID string
expectErr bool
}{
{
name: "track url",
input: "https://tidal.com/track/77616174",
wantType: "track",
wantID: "77616174",
},
{
name: "browse album url",
input: "https://listen.tidal.com/browse/album/77616169",
wantType: "album",
wantID: "77616169",
},
{
name: "artist url",
input: "https://www.tidal.com/artist/3852143",
wantType: "artist",
wantID: "3852143",
},
{
name: "playlist url",
input: "https://tidal.com/playlist/edf3b7d2-cb42-41d7-93c0-afa2a395521b",
wantType: "playlist",
wantID: "edf3b7d2-cb42-41d7-93c0-afa2a395521b",
},
{
name: "unsupported host",
input: "https://example.com/track/123",
expectErr: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
gotType, gotID, err := parseTidalURL(test.input)
if test.expectErr {
if err == nil {
t.Fatalf("expected error, got none")
}
return
}
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if gotType != test.wantType || gotID != test.wantID {
t.Fatalf("parseTidalURL(%q) = (%q, %q), want (%q, %q)", test.input, gotType, gotID, test.wantType, test.wantID)
}
})
}
}
func TestParseTidalRequestTrackID(t *testing.T) {
tests := []struct {
input string
want int64
ok bool
}{
{input: "40681594", want: 40681594, ok: true},
{input: "tidal:40681594", want: 40681594, ok: true},
{input: " tidal:40681594 ", want: 40681594, ok: true},
{input: "", want: 0, ok: false},
{input: "tidal:not-a-number", want: 0, ok: false},
}
for _, test := range tests {
got, ok := parseTidalRequestTrackID(test.input)
if got != test.want || ok != test.ok {
t.Fatalf("parseTidalRequestTrackID(%q) = (%d, %v), want (%d, %v)", test.input, got, ok, test.want, test.ok)
}
}
}
func TestTidalImageURL(t *testing.T) {
got := tidalImageURL("fc18a64b-d76b-4582-962a-224cb05193f3", "1280x1280")
want := "https://resources.tidal.com/images/fc18a64b/d76b/4582/962a/224cb05193f3/1280x1280.jpg"
if got != want {
t.Fatalf("tidalImageURL() = %q, want %q", got, want)
}
}
func TestTidalTrackToTrackMetadata(t *testing.T) {
track := &TidalTrack{
ID: 77616174,
Title: "Bruckner: Symphony No. 5",
ISRC: "GBUM71507433",
Duration: 1172,
TrackNumber: 5,
VolumeNumber: 1,
URL: "http://www.tidal.com/track/77616174",
}
track.Artist.ID = 3852143
track.Artist.Name = "Staatskapelle Berlin"
track.Artists = []struct {
ID int64 `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Picture string `json:"picture"`
}{
{ID: 3852143, Name: "Staatskapelle Berlin", Type: "MAIN"},
{ID: 12430, Name: "Daniel Barenboim", Type: "FEATURED"},
}
track.Album.ID = 77616169
track.Album.Title = "Bruckner: Symphonies 4-9"
track.Album.Cover = "fc18a64b-d76b-4582-962a-224cb05193f3"
track.Album.ReleaseDate = "2016-02-26"
got := tidalTrackToTrackMetadata(track)
if got.SpotifyID != "tidal:77616174" {
t.Fatalf("unexpected track ID: %q", got.SpotifyID)
}
if got.Artists != "Staatskapelle Berlin, Daniel Barenboim" {
t.Fatalf("unexpected artists: %q", got.Artists)
}
if got.AlbumID != "tidal:77616169" {
t.Fatalf("unexpected album ID: %q", got.AlbumID)
}
if got.ArtistID != "tidal:3852143" {
t.Fatalf("unexpected artist ID: %q", got.ArtistID)
}
if got.Images == "" || got.ExternalURL != "https://www.tidal.com/track/77616174" {
t.Fatalf("unexpected image/url: %q / %q", got.Images, got.ExternalURL)
}
}
func TestTidalAlbumToArtistAlbum(t *testing.T) {
album := &tidalPublicAlbum{
ID: 77616169,
Title: "Bruckner: Symphonies 4-9",
Type: "ALBUM",
Cover: "fc18a64b-d76b-4582-962a-224cb05193f3",
ReleaseDate: "2016-02-26",
NumberOfTracks: 23,
Artists: []tidalPublicArtist{
{ID: 3852143, Name: "Staatskapelle Berlin", Type: "MAIN"},
{ID: 12430, Name: "Daniel Barenboim", Type: "FEATURED"},
},
}
got := tidalAlbumToArtistAlbum(album)
if got.ID != "tidal:77616169" {
t.Fatalf("unexpected album ID: %q", got.ID)
}
if got.AlbumType != "album" {
t.Fatalf("unexpected album type: %q", got.AlbumType)
}
if got.Artists != "Staatskapelle Berlin, Daniel Barenboim" {
t.Fatalf("unexpected artists: %q", got.Artists)
}
if got.Images == "" {
t.Fatalf("expected image URL, got empty string")
}
}
func TestTidalAlbumToArtistAlbumWithFallbackType(t *testing.T) {
album := &tidalPublicAlbum{
ID: 490623904,
Title: "LET 'EM KNOW",
Cover: "fc18a64b-d76b-4582-962a-224cb05193f3",
NumberOfTracks: 1,
}
got := tidalAlbumToArtistAlbumWithType(album, "single")
if got.AlbumType != "single" {
t.Fatalf("unexpected fallback album type: %q", got.AlbumType)
}
}
func TestTidalArtistAlbumTypeFromModuleTitle(t *testing.T) {
tests := []struct {
title string
want string
}{
{title: "Albums", want: "album"},
{title: "EP & Singles", want: "single"},
{title: "Compilations", want: "album"},
{title: "Appears On", want: "album"},
{title: "Unknown", want: ""},
}
for _, test := range tests {
if got := tidalArtistAlbumTypeFromModuleTitle(test.title); got != test.want {
t.Fatalf("tidalArtistAlbumTypeFromModuleTitle(%q) = %q, want %q", test.title, got, test.want)
}
}
}
func TestTidalPlaylistImageUsesOrigin(t *testing.T) {
got := tidalImageURL("e6b59fd3-6995-40f0-8a32-174db3a8f4f2", "origin")
want := "https://resources.tidal.com/images/e6b59fd3/6995/40f0/8a32/174db3a8f4f2/origin.jpg"
if got != want {
t.Fatalf("unexpected origin playlist image URL: %q", got)
}
}
func TestTidalPlaylistOwnerName(t *testing.T) {
editorial := &tidalPublicPlaylist{Type: "EDITORIAL"}
if got := tidalPlaylistOwnerName(editorial); got != "TIDAL" {
t.Fatalf("unexpected editorial owner: %q", got)
}
artist := &tidalPublicPlaylist{Type: "ARTIST"}
if got := tidalPlaylistOwnerName(artist); got != "Artist" {
t.Fatalf("unexpected artist owner: %q", got)
}
user := &tidalPublicPlaylist{}
user.Creator.Name = "djtest"
if got := tidalPlaylistOwnerName(user); got != "djtest" {
t.Fatalf("unexpected creator owner: %q", got)
}
}
+75
View File
@@ -3,6 +3,8 @@ package gobackend
import (
"strings"
"unicode"
"golang.org/x/text/unicode/norm"
)
// normalizeLooseTitle collapses separators/punctuation so titles like
@@ -33,6 +35,37 @@ func normalizeLooseTitle(title string) string {
return strings.Join(strings.Fields(b.String()), " ")
}
// normalizeLooseArtistName folds diacritics and common separators so artist
// verification is resilient to variants like "Özkent" vs "Ozkent".
func normalizeLooseArtistName(name string) string {
trimmed := strings.TrimSpace(strings.ToLower(name))
if trimmed == "" {
return ""
}
decomposed := norm.NFD.String(trimmed)
var b strings.Builder
b.Grow(len(decomposed))
for _, r := range decomposed {
switch {
case unicode.Is(unicode.Mn, r), unicode.Is(unicode.Mc, r), unicode.Is(unicode.Me, r):
continue
case unicode.IsLetter(r), unicode.IsNumber(r):
b.WriteRune(r)
case unicode.IsSpace(r):
b.WriteByte(' ')
case r == '/', r == '\\', r == '_', r == '-', r == '|', r == '.', r == '&', r == '+':
b.WriteByte(' ')
default:
// Drop remaining punctuation/symbols for loose artist matching.
}
}
return strings.Join(strings.Fields(b.String()), " ")
}
func hasAlphaNumericRunes(value string) bool {
for _, r := range value {
if unicode.IsLetter(r) || unicode.IsNumber(r) {
@@ -68,3 +101,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
}
+5 -6
View File
@@ -11,7 +11,6 @@ import (
"strconv"
"strings"
"sync"
"time"
)
type YouTubeDownloader struct {
@@ -30,6 +29,7 @@ var (
type YouTubeQuality string
const (
YouTubeQualityOpus320 YouTubeQuality = "opus_320"
YouTubeQualityOpus256 YouTubeQuality = "opus_256"
YouTubeQualityOpus128 YouTubeQuality = "opus_128"
YouTubeQualityMP3128 YouTubeQuality = "mp3_128"
@@ -38,7 +38,7 @@ const (
)
var (
youtubeOpusSupportedBitrates = []int{128, 256}
youtubeOpusSupportedBitrates = []int{128, 256, 320}
youtubeMp3SupportedBitrates = []int{128, 256, 320}
)
@@ -82,7 +82,7 @@ type YouTubeDownloadResult struct {
func NewYouTubeDownloader() *YouTubeDownloader {
youtubeDownloaderOnce.Do(func() {
globalYouTubeDownloader = &YouTubeDownloader{
client: NewHTTPClientWithTimeout(120 * time.Second),
client: NewHTTPClientWithTimeout(DownloadTimeout),
apiURL: "https://api.qwkuns.me",
}
})
@@ -147,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", "":
@@ -511,12 +513,10 @@ func ExtractYouTubeVideoID(urlStr string) (string, error) {
return "", fmt.Errorf("invalid URL: %w", err)
}
// /watch?v=
if v := parsed.Query().Get("v"); v != "" {
return v, nil
}
// /embed/
if strings.Contains(parsed.Path, "/embed/") {
parts := strings.Split(parsed.Path, "/embed/")
if len(parts) >= 2 {
@@ -524,7 +524,6 @@ func ExtractYouTubeVideoID(urlStr string) (string, error) {
}
}
// /v/
if strings.Contains(parsed.Path, "/v/") {
parts := strings.Split(parsed.Path, "/v/")
if len(parts) >= 2 {
+15 -2
View File
@@ -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)
}
}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

+83 -43
View File
@@ -160,38 +160,6 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
case "getSpotifyMetadata":
let args = call.arguments as! [String: Any]
let url = args["url"] as! String
let response = GobackendGetSpotifyMetadata(url, &error)
if let error = error { throw error }
return response
case "searchSpotify":
let args = call.arguments as! [String: Any]
let query = args["query"] as! String
let limit = args["limit"] as? Int ?? 10
let response = GobackendSearchSpotify(query, Int(limit), &error)
if let error = error { throw error }
return response
case "searchSpotifyAll":
let args = call.arguments as! [String: Any]
let query = args["query"] as! String
let trackLimit = args["track_limit"] as? Int ?? 15
let artistLimit = args["artist_limit"] as? Int ?? 3
let response = GobackendSearchSpotifyAll(query, Int(trackLimit), Int(artistLimit), &error)
if let error = error { throw error }
return response
case "getSpotifyRelatedArtists":
let args = call.arguments as! [String: Any]
let artistId = args["artist_id"] as! String
let limit = args["limit"] as? Int ?? 12
let response = GobackendGetSpotifyRelatedArtists(artistId, Int(limit), &error)
if let error = error { throw error }
return response
case "checkAvailability":
let args = call.arguments as! [String: Any]
let spotifyId = args["spotify_id"] as! String
@@ -399,6 +367,26 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
case "searchTidalAll":
let args = call.arguments as! [String: Any]
let query = args["query"] as! String
let trackLimit = args["track_limit"] as? Int ?? 15
let artistLimit = args["artist_limit"] as? Int ?? 3
let filter = args["filter"] as? String ?? ""
let response = GobackendSearchTidalAll(query, Int(trackLimit), Int(artistLimit), filter, &error)
if let error = error { throw error }
return response
case "searchQobuzAll":
let args = call.arguments as! [String: Any]
let query = args["query"] as! String
let trackLimit = args["track_limit"] as? Int ?? 15
let artistLimit = args["artist_limit"] as? Int ?? 3
let filter = args["filter"] as? String ?? ""
let response = GobackendSearchQobuzAll(query, Int(trackLimit), Int(artistLimit), filter, &error)
if let error = error { throw error }
return response
case "getDeezerRelatedArtists":
let args = call.arguments as! [String: Any]
let artistId = args["artist_id"] as! String
@@ -415,6 +403,22 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
case "getQobuzMetadata":
let args = call.arguments as! [String: Any]
let resourceType = args["resource_type"] as! String
let resourceId = args["resource_id"] as! String
let response = GobackendGetQobuzMetadata(resourceType, resourceId, &error)
if let error = error { throw error }
return response
case "getTidalMetadata":
let args = call.arguments as! [String: Any]
let resourceType = args["resource_type"] as! String
let resourceId = args["resource_id"] as! String
let response = GobackendGetTidalMetadata(resourceType, resourceId, &error)
if let error = error { throw error }
return response
case "parseDeezerUrl":
let args = call.arguments as! [String: Any]
let url = args["url"] as! String
@@ -422,6 +426,13 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
case "parseQobuzUrl":
let args = call.arguments as! [String: Any]
let url = args["url"] as! String
let response = GobackendParseQobuzURLExport(url, &error)
if let error = error { throw error }
return response
case "parseTidalUrl":
let args = call.arguments as! [String: Any]
let url = args["url"] as! String
@@ -510,17 +521,6 @@ import Gobackend // Import Go framework
GobackendClearTrackCache()
return nil
case "setSpotifyCredentials":
let args = call.arguments as! [String: Any]
let clientId = args["client_id"] as! String
let clientSecret = args["client_secret"] as! String
GobackendSetSpotifyAPICredentials(clientId, clientSecret)
return nil
case "hasSpotifyCredentials":
let hasCredentials = GobackendCheckSpotifyCredentials()
return hasCredentials
// Log methods
case "getLogs":
let response = GobackendGetLogs()
@@ -643,6 +643,20 @@ import Gobackend // Import Go framework
let response = GobackendSearchTracksWithExtensionsJSON(query, Int(limit), &error)
if let error = error { throw error }
return response
case "searchTracksWithMetadataProviders":
let args = call.arguments as! [String: Any]
let query = args["query"] as! String
let limit = args["limit"] as? Int ?? 20
let includeExtensions = args["include_extensions"] as? Bool ?? true
let response = GobackendSearchTracksWithMetadataProvidersJSON(
query,
Int(limit),
includeExtensions,
&error
)
if let error = error { throw error }
return response
case "enrichTrackWithExtension":
let args = call.arguments as! [String: Any]
@@ -834,6 +848,23 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return nil
case "setStoreRegistryUrl":
let args = call.arguments as! [String: Any]
let registryUrl = args["registry_url"] as? String ?? ""
GobackendSetStoreRegistryURLJSON(registryUrl, &error)
if let error = error { throw error }
return nil
case "getStoreRegistryUrl":
let response = GobackendGetStoreRegistryURLJSON(&error)
if let error = error { throw error }
return response
case "clearStoreRegistryUrl":
GobackendClearStoreRegistryURLJSON(&error)
if let error = error { throw error }
return nil
case "getStoreExtensions":
let args = call.arguments as! [String: Any]
let forceRefresh = args["force_refresh"] as? Bool ?? false
@@ -969,6 +1000,15 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
// CUE Sheet Parsing
case "parseCueSheet":
let args = call.arguments as! [String: Any]
let cuePath = args["cue_path"] as! String
let audioDir = args["audio_dir"] as? String ?? ""
let response = GobackendParseCueSheet(cuePath, audioDir, &error)
if let error = error { throw error }
return response
default:
throw NSError(
domain: "SpotiFLAC",
+13 -8
View File
@@ -1,21 +1,26 @@
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.7.2';
static const String buildNumber = '105';
static const String version = '3.9.0';
static const String buildNumber = '115';
static const String fullVersion = '$version+$buildNumber';
/// Shows "Internal" in debug builds, actual version in release.
static String get displayVersion => kDebugMode ? 'Internal' : version;
static const String appName = 'SpotiFLAC';
static const String copyright = '© 2026 SpotiFLAC';
static const String mobileAuthor = 'zarzet';
static const String originalAuthor = 'afkarxyz';
static const String githubRepo = 'zarzet/SpotiFLAC-Mobile';
static const String githubUrl = 'https://github.com/$githubRepo';
static const String originalGithubUrl = 'https://github.com/afkarxyz/SpotiFLAC';
static const String originalGithubUrl =
'https://github.com/afkarxyz/SpotiFLAC';
static const String kofiUrl = 'https://ko-fi.com/zarzet';
static const String githubSponsorsUrl = 'https://github.com/sponsors/zarzet/';
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+623 -2
View File
@@ -525,6 +525,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get dialogImport => 'Import';
@override
String get dialogDownload => 'Download';
@override
String get dialogDiscard => 'Discard';
@@ -688,6 +691,17 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get errorNoTracksFound => 'No tracks found';
@override
String get errorUrlNotRecognized => 'Link not recognized';
@override
String get errorUrlNotRecognizedMessage =>
'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
@override
String get errorUrlFetchFailed =>
'Failed to load content from this link. Please try again.';
@override
String errorMissingExtensionSource(String item) {
return 'Cannot load $item: missing extension source';
@@ -761,6 +775,13 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get folderOrganizationNone => 'No organization';
@override
String get folderOrganizationByPlaylist => 'By Playlist';
@override
String get folderOrganizationByPlaylistSubtitle =>
'Separate folder for each playlist';
@override
String get folderOrganizationByArtist => 'By Artist';
@@ -1178,7 +1199,48 @@ class AppLocalizationsEn extends AppLocalizations {
String get storeClearFilters => 'Clear filters';
@override
String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
String get storeAddRepoTitle => 'Add Extension Repository';
@override
String get storeAddRepoDescription =>
'Enter a GitHub repository URL that contains a registry.json file to browse and install extensions.';
@override
String get storeRepoUrlLabel => 'Repository URL';
@override
String get storeRepoUrlHint => 'https://github.com/user/repo';
@override
String get storeRepoUrlHelper =>
'e.g. https://github.com/user/extensions-repo';
@override
String get storeAddRepoButton => 'Add Repository';
@override
String get storeChangeRepoTooltip => 'Change repository';
@override
String get storeRepoDialogTitle => 'Extension Repository';
@override
String get storeRepoDialogCurrent => 'Current repository:';
@override
String get storeNewRepoUrlLabel => 'New Repository URL';
@override
String get storeLoadError => 'Failed to load store';
@override
String get storeEmptyNoExtensions => 'No extensions available';
@override
String get storeEmptyNoResults => 'No extensions found';
@override
String get extensionDefaultProvider => 'Default (Deezer)';
@override
String get extensionDefaultProviderSubtitle => 'Use built-in search';
@@ -1327,6 +1389,38 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@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 qualityNote =>
'Actual quality depends on track availability from the service';
@@ -1633,6 +1727,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';
@@ -2083,6 +2196,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';
@@ -2092,7 +2227,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';
@@ -2115,6 +2251,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...';
@@ -2126,6 +2274,54 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get trackConvertFailed => 'Conversion failed';
@override
String get cueSplitTitle => 'Split CUE Sheet';
@override
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
@override
String cueSplitAlbum(String album) {
return 'Album: $album';
}
@override
String cueSplitArtist(String artist) {
return 'Artist: $artist';
}
@override
String cueSplitTrackCount(int count) {
return '$count tracks';
}
@override
String get cueSplitConfirmTitle => 'Split CUE Album';
@override
String cueSplitConfirmMessage(String album, int count) {
return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
}
@override
String cueSplitSplitting(int current, int total) {
return 'Splitting CUE sheet... ($current/$total)';
}
@override
String cueSplitSuccess(int count) {
return 'Split into $count tracks successfully';
}
@override
String get cueSplitFailed => 'CUE split failed';
@override
String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
@override
String get cueSplitButton => 'Split into Tracks';
@override
String get actionCreate => 'Create';
@@ -2320,6 +2516,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...';
@@ -2342,4 +2549,418 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override
String get lyricsProvidersTitle => 'Lyrics Providers';
@override
String get lyricsProvidersDescription =>
'Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.';
@override
String get lyricsProvidersInfoText =>
'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.';
@override
String lyricsProvidersEnabledSection(int count) {
return 'Enabled ($count)';
}
@override
String lyricsProvidersDisabledSection(int count) {
return 'Disabled ($count)';
}
@override
String get lyricsProvidersAtLeastOne =>
'At least one provider must remain enabled';
@override
String get lyricsProvidersSaved => 'Lyrics provider priority saved';
@override
String get lyricsProvidersDiscardContent =>
'You have unsaved changes that will be lost.';
@override
String get lyricsProviderSpotifyApiDesc =>
'Spotify-sourced synced lyrics via community API';
@override
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
@override
String get lyricsProviderNeteaseDesc =>
'NetEase Cloud Music (good for Asian songs)';
@override
String get lyricsProviderMusixmatchDesc =>
'Largest lyrics database (multi-language)';
@override
String get lyricsProviderAppleMusicDesc =>
'Word-by-word synced lyrics (via proxy)';
@override
String get lyricsProviderQqMusicDesc =>
'QQ Music (good for Chinese songs, via proxy)';
@override
String get lyricsProviderExtensionDesc => 'Extension provider';
@override
String get safMigrationTitle => 'Storage Update Required';
@override
String get safMigrationMessage1 =>
'SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. This fixes \"permission denied\" errors on Android 10+.';
@override
String get safMigrationMessage2 =>
'Please select your download folder again to switch to the new storage system.';
@override
String get safMigrationSuccess => 'Download folder updated to SAF mode';
@override
String get settingsDonate => 'Donate';
@override
String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development';
@override
String get tooltipLoveAll => 'Love All';
@override
String get tooltipAddToPlaylist => 'Add to Playlist';
@override
String snackbarRemovedTracksFromLoved(int count) {
return 'Removed $count tracks from Loved';
}
@override
String snackbarAddedTracksToLoved(int count) {
return 'Added $count tracks to Loved';
}
@override
String get dialogDownloadAllTitle => 'Download All';
@override
String dialogDownloadAllMessage(int count) {
return 'Download $count tracks?';
}
@override
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
@override
String get homeGoToAlbum => 'Go to Album';
@override
String get homeAlbumInfoUnavailable => 'Album info not available';
@override
String get snackbarLoadingCueSheet => 'Loading CUE sheet...';
@override
String get snackbarMetadataSaved => 'Metadata saved successfully';
@override
String get snackbarFailedToEmbedLyrics => 'Failed to embed lyrics';
@override
String get snackbarFailedToWriteStorage => 'Failed to write back to storage';
@override
String snackbarError(String error) {
return 'Error: $error';
}
@override
String get snackbarNoActionDefined => 'No action defined for this button';
@override
String get noTracksFoundForAlbum => 'No tracks found for this album';
@override
String get downloadLocationSubtitle =>
'Choose storage mode for downloaded files.';
@override
String get storageModeAppFolder => 'App folder (non-SAF)';
@override
String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path';
@override
String get storageModeSaf => 'SAF folder';
@override
String get storageModeSafSubtitle =>
'Pick folder via Android Storage Access Framework';
@override
String get downloadFilenameDescription =>
'Customize how your files are named.';
@override
String get downloadFilenameInsertTag => 'Tap to insert tag:';
@override
String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders';
@override
String get downloadSeparateSinglesDisabled => 'All files in same structure';
@override
String get downloadArtistNameFilters => 'Artist Name Filters';
@override
String get downloadCreatePlaylistSourceFolder =>
'Create playlist source folder';
@override
String get downloadCreatePlaylistSourceFolderEnabled =>
'Playlist downloads use Playlist/ plus your normal folder structure.';
@override
String get downloadCreatePlaylistSourceFolderDisabled =>
'Playlist downloads use the normal folder structure only.';
@override
String get downloadCreatePlaylistSourceFolderRedundant =>
'By Playlist already places downloads inside a playlist folder.';
@override
String get downloadSongLinkRegion => 'SongLink Region';
@override
String get downloadNetworkCompatibilityMode => 'Network compatibility mode';
@override
String get downloadNetworkCompatibilityModeEnabled =>
'Enabled: try HTTP + accept invalid TLS certificates (unsafe)';
@override
String get downloadNetworkCompatibilityModeDisabled =>
'Off: strict HTTPS certificate validation (recommended)';
@override
String get downloadSelectServiceToEnable =>
'Select a built-in service to enable';
@override
String get downloadSelectTidalQobuz =>
'Select Tidal or Qobuz above to configure quality';
@override
String get downloadEmbedLyricsDisabled =>
'Disabled while Embed Metadata is turned off';
@override
String get downloadNeteaseIncludeTranslation =>
'Netease: Include Translation';
@override
String get downloadNeteaseIncludeTranslationEnabled =>
'Append translated lyrics when available';
@override
String get downloadNeteaseIncludeTranslationDisabled =>
'Use original lyrics only';
@override
String get downloadNeteaseIncludeRomanization =>
'Netease: Include Romanization';
@override
String get downloadNeteaseIncludeRomanizationEnabled =>
'Append romanized lyrics when available';
@override
String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled';
@override
String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word';
@override
String get downloadAppleQqMultiPersonEnabled =>
'Enable v1/v2 speaker and [bg:] tags';
@override
String get downloadAppleQqMultiPersonDisabled =>
'Simplified word-by-word formatting';
@override
String get downloadMusixmatchLanguage => 'Musixmatch Language';
@override
String get downloadMusixmatchLanguageAuto => 'Auto (original)';
@override
String get downloadFilterContributing =>
'Filter contributing artists in Album Artist';
@override
String get downloadFilterContributingEnabled =>
'Album Artist metadata uses primary artist only';
@override
String get downloadFilterContributingDisabled =>
'Keep full Album Artist metadata value';
@override
String get downloadProvidersNoneEnabled => 'None enabled';
@override
String get downloadMusixmatchLanguageCode => 'Language code';
@override
String get downloadMusixmatchLanguageHint => 'auto / en / es / ja';
@override
String get downloadMusixmatchLanguageDesc =>
'Set preferred language code (example: en, es, ja). Leave empty for auto.';
@override
String get downloadMusixmatchAuto => 'Auto';
@override
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
@override
String get downloadNetworkWifiOnlySubtitle =>
'Pause downloads on mobile data';
@override
String get downloadSongLinkRegionDesc =>
'Used as userCountry for SongLink API lookup.';
@override
String get snackbarUnsupportedAudioFormat => 'Unsupported audio format';
@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';
}
+622 -1
View File
@@ -525,6 +525,9 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get dialogImport => 'Import';
@override
String get dialogDownload => 'Download';
@override
String get dialogDiscard => 'Discard';
@@ -688,6 +691,17 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get errorNoTracksFound => 'No tracks found';
@override
String get errorUrlNotRecognized => 'Link not recognized';
@override
String get errorUrlNotRecognizedMessage =>
'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
@override
String get errorUrlFetchFailed =>
'Failed to load content from this link. Please try again.';
@override
String errorMissingExtensionSource(String item) {
return 'Cannot load $item: missing extension source';
@@ -761,6 +775,13 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get folderOrganizationNone => 'No organization';
@override
String get folderOrganizationByPlaylist => 'By Playlist';
@override
String get folderOrganizationByPlaylistSubtitle =>
'Separate folder for each playlist';
@override
String get folderOrganizationByArtist => 'By Artist';
@@ -1177,6 +1198,47 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get storeClearFilters => 'Clear filters';
@override
String get storeAddRepoTitle => 'Add Extension Repository';
@override
String get storeAddRepoDescription =>
'Enter a GitHub repository URL that contains a registry.json file to browse and install extensions.';
@override
String get storeRepoUrlLabel => 'Repository URL';
@override
String get storeRepoUrlHint => 'https://github.com/user/repo';
@override
String get storeRepoUrlHelper =>
'e.g. https://github.com/user/extensions-repo';
@override
String get storeAddRepoButton => 'Add Repository';
@override
String get storeChangeRepoTooltip => 'Change repository';
@override
String get storeRepoDialogTitle => 'Extension Repository';
@override
String get storeRepoDialogCurrent => 'Current repository:';
@override
String get storeNewRepoUrlLabel => 'New Repository URL';
@override
String get storeLoadError => 'Failed to load store';
@override
String get storeEmptyNoExtensions => 'No extensions available';
@override
String get storeEmptyNoResults => 'No extensions found';
@override
String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
@@ -1327,6 +1389,38 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@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 qualityNote =>
'Actual quality depends on track availability from the service';
@@ -1633,6 +1727,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';
@@ -2083,6 +2196,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';
@@ -2092,7 +2227,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';
@@ -2115,6 +2251,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...';
@@ -2126,6 +2274,54 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get trackConvertFailed => 'Conversion failed';
@override
String get cueSplitTitle => 'Split CUE Sheet';
@override
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
@override
String cueSplitAlbum(String album) {
return 'Album: $album';
}
@override
String cueSplitArtist(String artist) {
return 'Artist: $artist';
}
@override
String cueSplitTrackCount(int count) {
return '$count tracks';
}
@override
String get cueSplitConfirmTitle => 'Split CUE Album';
@override
String cueSplitConfirmMessage(String album, int count) {
return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
}
@override
String cueSplitSplitting(int current, int total) {
return 'Splitting CUE sheet... ($current/$total)';
}
@override
String cueSplitSuccess(int count) {
return 'Split into $count tracks successfully';
}
@override
String get cueSplitFailed => 'CUE split failed';
@override
String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
@override
String get cueSplitButton => 'Split into Tracks';
@override
String get actionCreate => 'Create';
@@ -2320,6 +2516,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...';
@@ -2342,6 +2549,420 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override
String get lyricsProvidersTitle => 'Lyrics Providers';
@override
String get lyricsProvidersDescription =>
'Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.';
@override
String get lyricsProvidersInfoText =>
'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.';
@override
String lyricsProvidersEnabledSection(int count) {
return 'Enabled ($count)';
}
@override
String lyricsProvidersDisabledSection(int count) {
return 'Disabled ($count)';
}
@override
String get lyricsProvidersAtLeastOne =>
'At least one provider must remain enabled';
@override
String get lyricsProvidersSaved => 'Lyrics provider priority saved';
@override
String get lyricsProvidersDiscardContent =>
'You have unsaved changes that will be lost.';
@override
String get lyricsProviderSpotifyApiDesc =>
'Spotify-sourced synced lyrics via community API';
@override
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
@override
String get lyricsProviderNeteaseDesc =>
'NetEase Cloud Music (good for Asian songs)';
@override
String get lyricsProviderMusixmatchDesc =>
'Largest lyrics database (multi-language)';
@override
String get lyricsProviderAppleMusicDesc =>
'Word-by-word synced lyrics (via proxy)';
@override
String get lyricsProviderQqMusicDesc =>
'QQ Music (good for Chinese songs, via proxy)';
@override
String get lyricsProviderExtensionDesc => 'Extension provider';
@override
String get safMigrationTitle => 'Storage Update Required';
@override
String get safMigrationMessage1 =>
'SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. This fixes \"permission denied\" errors on Android 10+.';
@override
String get safMigrationMessage2 =>
'Please select your download folder again to switch to the new storage system.';
@override
String get safMigrationSuccess => 'Download folder updated to SAF mode';
@override
String get settingsDonate => 'Donate';
@override
String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development';
@override
String get tooltipLoveAll => 'Love All';
@override
String get tooltipAddToPlaylist => 'Add to Playlist';
@override
String snackbarRemovedTracksFromLoved(int count) {
return 'Removed $count tracks from Loved';
}
@override
String snackbarAddedTracksToLoved(int count) {
return 'Added $count tracks to Loved';
}
@override
String get dialogDownloadAllTitle => 'Download All';
@override
String dialogDownloadAllMessage(int count) {
return 'Download $count tracks?';
}
@override
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
@override
String get homeGoToAlbum => 'Go to Album';
@override
String get homeAlbumInfoUnavailable => 'Album info not available';
@override
String get snackbarLoadingCueSheet => 'Loading CUE sheet...';
@override
String get snackbarMetadataSaved => 'Metadata saved successfully';
@override
String get snackbarFailedToEmbedLyrics => 'Failed to embed lyrics';
@override
String get snackbarFailedToWriteStorage => 'Failed to write back to storage';
@override
String snackbarError(String error) {
return 'Error: $error';
}
@override
String get snackbarNoActionDefined => 'No action defined for this button';
@override
String get noTracksFoundForAlbum => 'No tracks found for this album';
@override
String get downloadLocationSubtitle =>
'Choose storage mode for downloaded files.';
@override
String get storageModeAppFolder => 'App folder (non-SAF)';
@override
String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path';
@override
String get storageModeSaf => 'SAF folder';
@override
String get storageModeSafSubtitle =>
'Pick folder via Android Storage Access Framework';
@override
String get downloadFilenameDescription =>
'Customize how your files are named.';
@override
String get downloadFilenameInsertTag => 'Tap to insert tag:';
@override
String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders';
@override
String get downloadSeparateSinglesDisabled => 'All files in same structure';
@override
String get downloadArtistNameFilters => 'Artist Name Filters';
@override
String get downloadCreatePlaylistSourceFolder =>
'Create playlist source folder';
@override
String get downloadCreatePlaylistSourceFolderEnabled =>
'Playlist downloads use Playlist/ plus your normal folder structure.';
@override
String get downloadCreatePlaylistSourceFolderDisabled =>
'Playlist downloads use the normal folder structure only.';
@override
String get downloadCreatePlaylistSourceFolderRedundant =>
'By Playlist already places downloads inside a playlist folder.';
@override
String get downloadSongLinkRegion => 'SongLink Region';
@override
String get downloadNetworkCompatibilityMode => 'Network compatibility mode';
@override
String get downloadNetworkCompatibilityModeEnabled =>
'Enabled: try HTTP + accept invalid TLS certificates (unsafe)';
@override
String get downloadNetworkCompatibilityModeDisabled =>
'Off: strict HTTPS certificate validation (recommended)';
@override
String get downloadSelectServiceToEnable =>
'Select a built-in service to enable';
@override
String get downloadSelectTidalQobuz =>
'Select Tidal or Qobuz above to configure quality';
@override
String get downloadEmbedLyricsDisabled =>
'Disabled while Embed Metadata is turned off';
@override
String get downloadNeteaseIncludeTranslation =>
'Netease: Include Translation';
@override
String get downloadNeteaseIncludeTranslationEnabled =>
'Append translated lyrics when available';
@override
String get downloadNeteaseIncludeTranslationDisabled =>
'Use original lyrics only';
@override
String get downloadNeteaseIncludeRomanization =>
'Netease: Include Romanization';
@override
String get downloadNeteaseIncludeRomanizationEnabled =>
'Append romanized lyrics when available';
@override
String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled';
@override
String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word';
@override
String get downloadAppleQqMultiPersonEnabled =>
'Enable v1/v2 speaker and [bg:] tags';
@override
String get downloadAppleQqMultiPersonDisabled =>
'Simplified word-by-word formatting';
@override
String get downloadMusixmatchLanguage => 'Musixmatch Language';
@override
String get downloadMusixmatchLanguageAuto => 'Auto (original)';
@override
String get downloadFilterContributing =>
'Filter contributing artists in Album Artist';
@override
String get downloadFilterContributingEnabled =>
'Album Artist metadata uses primary artist only';
@override
String get downloadFilterContributingDisabled =>
'Keep full Album Artist metadata value';
@override
String get downloadProvidersNoneEnabled => 'None enabled';
@override
String get downloadMusixmatchLanguageCode => 'Language code';
@override
String get downloadMusixmatchLanguageHint => 'auto / en / es / ja';
@override
String get downloadMusixmatchLanguageDesc =>
'Set preferred language code (example: en, es, ja). Leave empty for auto.';
@override
String get downloadMusixmatchAuto => 'Auto';
@override
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
@override
String get downloadNetworkWifiOnlySubtitle =>
'Pause downloads on mobile data';
@override
String get downloadSongLinkRegionDesc =>
'Used as userCountry for SongLink API lookup.';
@override
String get snackbarUnsupportedAudioFormat => 'Unsupported audio format';
@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`).
+622 -2
View File
@@ -358,7 +358,7 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
@override
String get artistAlbums => 'Albums';
@@ -527,6 +527,9 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get dialogImport => 'Import';
@override
String get dialogDownload => 'Download';
@override
String get dialogDiscard => 'Discard';
@@ -690,6 +693,17 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get errorNoTracksFound => 'No tracks found';
@override
String get errorUrlNotRecognized => 'Link not recognized';
@override
String get errorUrlNotRecognizedMessage =>
'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
@override
String get errorUrlFetchFailed =>
'Failed to load content from this link. Please try again.';
@override
String errorMissingExtensionSource(String item) {
return 'Cannot load $item: missing extension source';
@@ -763,6 +777,13 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get folderOrganizationNone => 'No organization';
@override
String get folderOrganizationByPlaylist => 'By Playlist';
@override
String get folderOrganizationByPlaylistSubtitle =>
'Separate folder for each playlist';
@override
String get folderOrganizationByArtist => 'By Artist';
@@ -1179,6 +1200,47 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get storeClearFilters => 'Clear filters';
@override
String get storeAddRepoTitle => 'Add Extension Repository';
@override
String get storeAddRepoDescription =>
'Enter a GitHub repository URL that contains a registry.json file to browse and install extensions.';
@override
String get storeRepoUrlLabel => 'Repository URL';
@override
String get storeRepoUrlHint => 'https://github.com/user/repo';
@override
String get storeRepoUrlHelper =>
'e.g. https://github.com/user/extensions-repo';
@override
String get storeAddRepoButton => 'Add Repository';
@override
String get storeChangeRepoTooltip => 'Change repository';
@override
String get storeRepoDialogTitle => 'Extension Repository';
@override
String get storeRepoDialogCurrent => 'Current repository:';
@override
String get storeNewRepoUrlLabel => 'New Repository URL';
@override
String get storeLoadError => 'Failed to load store';
@override
String get storeEmptyNoExtensions => 'No extensions available';
@override
String get storeEmptyNoResults => 'No extensions found';
@override
String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
@@ -1329,6 +1391,38 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@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 qualityNote =>
'Actual quality depends on track availability from the service';
@@ -1635,6 +1729,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';
@@ -1811,7 +1924,7 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get tutorialWelcomeTip2 =>
'Audio en qualité FLAC depuis Tidal, Qobuz ou Deezer';
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
@override
String get tutorialWelcomeTip3 =>
@@ -2085,6 +2198,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';
@@ -2117,6 +2252,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...';
@@ -2128,6 +2275,54 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get trackConvertFailed => 'Conversion failed';
@override
String get cueSplitTitle => 'Split CUE Sheet';
@override
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
@override
String cueSplitAlbum(String album) {
return 'Album: $album';
}
@override
String cueSplitArtist(String artist) {
return 'Artist: $artist';
}
@override
String cueSplitTrackCount(int count) {
return '$count tracks';
}
@override
String get cueSplitConfirmTitle => 'Split CUE Album';
@override
String cueSplitConfirmMessage(String album, int count) {
return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
}
@override
String cueSplitSplitting(int current, int total) {
return 'Splitting CUE sheet... ($current/$total)';
}
@override
String cueSplitSuccess(int count) {
return 'Split into $count tracks successfully';
}
@override
String get cueSplitFailed => 'CUE split failed';
@override
String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
@override
String get cueSplitButton => 'Split into Tracks';
@override
String get actionCreate => 'Create';
@@ -2322,6 +2517,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...';
@@ -2344,4 +2550,418 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override
String get lyricsProvidersTitle => 'Lyrics Providers';
@override
String get lyricsProvidersDescription =>
'Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.';
@override
String get lyricsProvidersInfoText =>
'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.';
@override
String lyricsProvidersEnabledSection(int count) {
return 'Enabled ($count)';
}
@override
String lyricsProvidersDisabledSection(int count) {
return 'Disabled ($count)';
}
@override
String get lyricsProvidersAtLeastOne =>
'At least one provider must remain enabled';
@override
String get lyricsProvidersSaved => 'Lyrics provider priority saved';
@override
String get lyricsProvidersDiscardContent =>
'You have unsaved changes that will be lost.';
@override
String get lyricsProviderSpotifyApiDesc =>
'Spotify-sourced synced lyrics via community API';
@override
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
@override
String get lyricsProviderNeteaseDesc =>
'NetEase Cloud Music (good for Asian songs)';
@override
String get lyricsProviderMusixmatchDesc =>
'Largest lyrics database (multi-language)';
@override
String get lyricsProviderAppleMusicDesc =>
'Word-by-word synced lyrics (via proxy)';
@override
String get lyricsProviderQqMusicDesc =>
'QQ Music (good for Chinese songs, via proxy)';
@override
String get lyricsProviderExtensionDesc => 'Extension provider';
@override
String get safMigrationTitle => 'Storage Update Required';
@override
String get safMigrationMessage1 =>
'SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. This fixes \"permission denied\" errors on Android 10+.';
@override
String get safMigrationMessage2 =>
'Please select your download folder again to switch to the new storage system.';
@override
String get safMigrationSuccess => 'Download folder updated to SAF mode';
@override
String get settingsDonate => 'Donate';
@override
String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development';
@override
String get tooltipLoveAll => 'Love All';
@override
String get tooltipAddToPlaylist => 'Add to Playlist';
@override
String snackbarRemovedTracksFromLoved(int count) {
return 'Removed $count tracks from Loved';
}
@override
String snackbarAddedTracksToLoved(int count) {
return 'Added $count tracks to Loved';
}
@override
String get dialogDownloadAllTitle => 'Download All';
@override
String dialogDownloadAllMessage(int count) {
return 'Download $count tracks?';
}
@override
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
@override
String get homeGoToAlbum => 'Go to Album';
@override
String get homeAlbumInfoUnavailable => 'Album info not available';
@override
String get snackbarLoadingCueSheet => 'Loading CUE sheet...';
@override
String get snackbarMetadataSaved => 'Metadata saved successfully';
@override
String get snackbarFailedToEmbedLyrics => 'Failed to embed lyrics';
@override
String get snackbarFailedToWriteStorage => 'Failed to write back to storage';
@override
String snackbarError(String error) {
return 'Error: $error';
}
@override
String get snackbarNoActionDefined => 'No action defined for this button';
@override
String get noTracksFoundForAlbum => 'No tracks found for this album';
@override
String get downloadLocationSubtitle =>
'Choose storage mode for downloaded files.';
@override
String get storageModeAppFolder => 'App folder (non-SAF)';
@override
String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path';
@override
String get storageModeSaf => 'SAF folder';
@override
String get storageModeSafSubtitle =>
'Pick folder via Android Storage Access Framework';
@override
String get downloadFilenameDescription =>
'Customize how your files are named.';
@override
String get downloadFilenameInsertTag => 'Tap to insert tag:';
@override
String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders';
@override
String get downloadSeparateSinglesDisabled => 'All files in same structure';
@override
String get downloadArtistNameFilters => 'Artist Name Filters';
@override
String get downloadCreatePlaylistSourceFolder =>
'Create playlist source folder';
@override
String get downloadCreatePlaylistSourceFolderEnabled =>
'Playlist downloads use Playlist/ plus your normal folder structure.';
@override
String get downloadCreatePlaylistSourceFolderDisabled =>
'Playlist downloads use the normal folder structure only.';
@override
String get downloadCreatePlaylistSourceFolderRedundant =>
'By Playlist already places downloads inside a playlist folder.';
@override
String get downloadSongLinkRegion => 'SongLink Region';
@override
String get downloadNetworkCompatibilityMode => 'Network compatibility mode';
@override
String get downloadNetworkCompatibilityModeEnabled =>
'Enabled: try HTTP + accept invalid TLS certificates (unsafe)';
@override
String get downloadNetworkCompatibilityModeDisabled =>
'Off: strict HTTPS certificate validation (recommended)';
@override
String get downloadSelectServiceToEnable =>
'Select a built-in service to enable';
@override
String get downloadSelectTidalQobuz =>
'Select Tidal or Qobuz above to configure quality';
@override
String get downloadEmbedLyricsDisabled =>
'Disabled while Embed Metadata is turned off';
@override
String get downloadNeteaseIncludeTranslation =>
'Netease: Include Translation';
@override
String get downloadNeteaseIncludeTranslationEnabled =>
'Append translated lyrics when available';
@override
String get downloadNeteaseIncludeTranslationDisabled =>
'Use original lyrics only';
@override
String get downloadNeteaseIncludeRomanization =>
'Netease: Include Romanization';
@override
String get downloadNeteaseIncludeRomanizationEnabled =>
'Append romanized lyrics when available';
@override
String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled';
@override
String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word';
@override
String get downloadAppleQqMultiPersonEnabled =>
'Enable v1/v2 speaker and [bg:] tags';
@override
String get downloadAppleQqMultiPersonDisabled =>
'Simplified word-by-word formatting';
@override
String get downloadMusixmatchLanguage => 'Musixmatch Language';
@override
String get downloadMusixmatchLanguageAuto => 'Auto (original)';
@override
String get downloadFilterContributing =>
'Filter contributing artists in Album Artist';
@override
String get downloadFilterContributingEnabled =>
'Album Artist metadata uses primary artist only';
@override
String get downloadFilterContributingDisabled =>
'Keep full Album Artist metadata value';
@override
String get downloadProvidersNoneEnabled => 'None enabled';
@override
String get downloadMusixmatchLanguageCode => 'Language code';
@override
String get downloadMusixmatchLanguageHint => 'auto / en / es / ja';
@override
String get downloadMusixmatchLanguageDesc =>
'Set preferred language code (example: en, es, ja). Leave empty for auto.';
@override
String get downloadMusixmatchAuto => 'Auto';
@override
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
@override
String get downloadNetworkWifiOnlySubtitle =>
'Pause downloads on mobile data';
@override
String get downloadSongLinkRegionDesc =>
'Used as userCountry for SongLink API lookup.';
@override
String get snackbarUnsupportedAudioFormat => 'Unsupported audio format';
@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';
}
+622 -2
View File
@@ -356,7 +356,7 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
@override
String get artistAlbums => 'Albums';
@@ -525,6 +525,9 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get dialogImport => 'Import';
@override
String get dialogDownload => 'Download';
@override
String get dialogDiscard => 'Discard';
@@ -688,6 +691,17 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get errorNoTracksFound => 'No tracks found';
@override
String get errorUrlNotRecognized => 'Link not recognized';
@override
String get errorUrlNotRecognizedMessage =>
'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
@override
String get errorUrlFetchFailed =>
'Failed to load content from this link. Please try again.';
@override
String errorMissingExtensionSource(String item) {
return 'Cannot load $item: missing extension source';
@@ -761,6 +775,13 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get folderOrganizationNone => 'No organization';
@override
String get folderOrganizationByPlaylist => 'By Playlist';
@override
String get folderOrganizationByPlaylistSubtitle =>
'Separate folder for each playlist';
@override
String get folderOrganizationByArtist => 'By Artist';
@@ -1177,6 +1198,47 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get storeClearFilters => 'Clear filters';
@override
String get storeAddRepoTitle => 'Add Extension Repository';
@override
String get storeAddRepoDescription =>
'Enter a GitHub repository URL that contains a registry.json file to browse and install extensions.';
@override
String get storeRepoUrlLabel => 'Repository URL';
@override
String get storeRepoUrlHint => 'https://github.com/user/repo';
@override
String get storeRepoUrlHelper =>
'e.g. https://github.com/user/extensions-repo';
@override
String get storeAddRepoButton => 'Add Repository';
@override
String get storeChangeRepoTooltip => 'Change repository';
@override
String get storeRepoDialogTitle => 'Extension Repository';
@override
String get storeRepoDialogCurrent => 'Current repository:';
@override
String get storeNewRepoUrlLabel => 'New Repository URL';
@override
String get storeLoadError => 'Failed to load store';
@override
String get storeEmptyNoExtensions => 'No extensions available';
@override
String get storeEmptyNoResults => 'No extensions found';
@override
String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
@@ -1327,6 +1389,38 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@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 qualityNote =>
'Actual quality depends on track availability from the service';
@@ -1633,6 +1727,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';
@@ -1809,7 +1922,7 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get tutorialWelcomeTip2 =>
'Tidal, Qobuz, या Deezer से FLAC गुणवत्ता ऑडियो प्राप्त करें';
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
@override
String get tutorialWelcomeTip3 =>
@@ -2083,6 +2196,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';
@@ -2115,6 +2250,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...';
@@ -2126,6 +2273,54 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get trackConvertFailed => 'Conversion failed';
@override
String get cueSplitTitle => 'Split CUE Sheet';
@override
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
@override
String cueSplitAlbum(String album) {
return 'Album: $album';
}
@override
String cueSplitArtist(String artist) {
return 'Artist: $artist';
}
@override
String cueSplitTrackCount(int count) {
return '$count tracks';
}
@override
String get cueSplitConfirmTitle => 'Split CUE Album';
@override
String cueSplitConfirmMessage(String album, int count) {
return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
}
@override
String cueSplitSplitting(int current, int total) {
return 'Splitting CUE sheet... ($current/$total)';
}
@override
String cueSplitSuccess(int count) {
return 'Split into $count tracks successfully';
}
@override
String get cueSplitFailed => 'CUE split failed';
@override
String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
@override
String get cueSplitButton => 'Split into Tracks';
@override
String get actionCreate => 'Create';
@@ -2320,6 +2515,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...';
@@ -2342,4 +2548,418 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override
String get lyricsProvidersTitle => 'Lyrics Providers';
@override
String get lyricsProvidersDescription =>
'Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.';
@override
String get lyricsProvidersInfoText =>
'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.';
@override
String lyricsProvidersEnabledSection(int count) {
return 'Enabled ($count)';
}
@override
String lyricsProvidersDisabledSection(int count) {
return 'Disabled ($count)';
}
@override
String get lyricsProvidersAtLeastOne =>
'At least one provider must remain enabled';
@override
String get lyricsProvidersSaved => 'Lyrics provider priority saved';
@override
String get lyricsProvidersDiscardContent =>
'You have unsaved changes that will be lost.';
@override
String get lyricsProviderSpotifyApiDesc =>
'Spotify-sourced synced lyrics via community API';
@override
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
@override
String get lyricsProviderNeteaseDesc =>
'NetEase Cloud Music (good for Asian songs)';
@override
String get lyricsProviderMusixmatchDesc =>
'Largest lyrics database (multi-language)';
@override
String get lyricsProviderAppleMusicDesc =>
'Word-by-word synced lyrics (via proxy)';
@override
String get lyricsProviderQqMusicDesc =>
'QQ Music (good for Chinese songs, via proxy)';
@override
String get lyricsProviderExtensionDesc => 'Extension provider';
@override
String get safMigrationTitle => 'Storage Update Required';
@override
String get safMigrationMessage1 =>
'SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. This fixes \"permission denied\" errors on Android 10+.';
@override
String get safMigrationMessage2 =>
'Please select your download folder again to switch to the new storage system.';
@override
String get safMigrationSuccess => 'Download folder updated to SAF mode';
@override
String get settingsDonate => 'Donate';
@override
String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development';
@override
String get tooltipLoveAll => 'Love All';
@override
String get tooltipAddToPlaylist => 'Add to Playlist';
@override
String snackbarRemovedTracksFromLoved(int count) {
return 'Removed $count tracks from Loved';
}
@override
String snackbarAddedTracksToLoved(int count) {
return 'Added $count tracks to Loved';
}
@override
String get dialogDownloadAllTitle => 'Download All';
@override
String dialogDownloadAllMessage(int count) {
return 'Download $count tracks?';
}
@override
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
@override
String get homeGoToAlbum => 'Go to Album';
@override
String get homeAlbumInfoUnavailable => 'Album info not available';
@override
String get snackbarLoadingCueSheet => 'Loading CUE sheet...';
@override
String get snackbarMetadataSaved => 'Metadata saved successfully';
@override
String get snackbarFailedToEmbedLyrics => 'Failed to embed lyrics';
@override
String get snackbarFailedToWriteStorage => 'Failed to write back to storage';
@override
String snackbarError(String error) {
return 'Error: $error';
}
@override
String get snackbarNoActionDefined => 'No action defined for this button';
@override
String get noTracksFoundForAlbum => 'No tracks found for this album';
@override
String get downloadLocationSubtitle =>
'Choose storage mode for downloaded files.';
@override
String get storageModeAppFolder => 'App folder (non-SAF)';
@override
String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path';
@override
String get storageModeSaf => 'SAF folder';
@override
String get storageModeSafSubtitle =>
'Pick folder via Android Storage Access Framework';
@override
String get downloadFilenameDescription =>
'Customize how your files are named.';
@override
String get downloadFilenameInsertTag => 'Tap to insert tag:';
@override
String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders';
@override
String get downloadSeparateSinglesDisabled => 'All files in same structure';
@override
String get downloadArtistNameFilters => 'Artist Name Filters';
@override
String get downloadCreatePlaylistSourceFolder =>
'Create playlist source folder';
@override
String get downloadCreatePlaylistSourceFolderEnabled =>
'Playlist downloads use Playlist/ plus your normal folder structure.';
@override
String get downloadCreatePlaylistSourceFolderDisabled =>
'Playlist downloads use the normal folder structure only.';
@override
String get downloadCreatePlaylistSourceFolderRedundant =>
'By Playlist already places downloads inside a playlist folder.';
@override
String get downloadSongLinkRegion => 'SongLink Region';
@override
String get downloadNetworkCompatibilityMode => 'Network compatibility mode';
@override
String get downloadNetworkCompatibilityModeEnabled =>
'Enabled: try HTTP + accept invalid TLS certificates (unsafe)';
@override
String get downloadNetworkCompatibilityModeDisabled =>
'Off: strict HTTPS certificate validation (recommended)';
@override
String get downloadSelectServiceToEnable =>
'Select a built-in service to enable';
@override
String get downloadSelectTidalQobuz =>
'Select Tidal or Qobuz above to configure quality';
@override
String get downloadEmbedLyricsDisabled =>
'Disabled while Embed Metadata is turned off';
@override
String get downloadNeteaseIncludeTranslation =>
'Netease: Include Translation';
@override
String get downloadNeteaseIncludeTranslationEnabled =>
'Append translated lyrics when available';
@override
String get downloadNeteaseIncludeTranslationDisabled =>
'Use original lyrics only';
@override
String get downloadNeteaseIncludeRomanization =>
'Netease: Include Romanization';
@override
String get downloadNeteaseIncludeRomanizationEnabled =>
'Append romanized lyrics when available';
@override
String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled';
@override
String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word';
@override
String get downloadAppleQqMultiPersonEnabled =>
'Enable v1/v2 speaker and [bg:] tags';
@override
String get downloadAppleQqMultiPersonDisabled =>
'Simplified word-by-word formatting';
@override
String get downloadMusixmatchLanguage => 'Musixmatch Language';
@override
String get downloadMusixmatchLanguageAuto => 'Auto (original)';
@override
String get downloadFilterContributing =>
'Filter contributing artists in Album Artist';
@override
String get downloadFilterContributingEnabled =>
'Album Artist metadata uses primary artist only';
@override
String get downloadFilterContributingDisabled =>
'Keep full Album Artist metadata value';
@override
String get downloadProvidersNoneEnabled => 'None enabled';
@override
String get downloadMusixmatchLanguageCode => 'Language code';
@override
String get downloadMusixmatchLanguageHint => 'auto / en / es / ja';
@override
String get downloadMusixmatchLanguageDesc =>
'Set preferred language code (example: en, es, ja). Leave empty for auto.';
@override
String get downloadMusixmatchAuto => 'Auto';
@override
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
@override
String get downloadNetworkWifiOnlySubtitle =>
'Pause downloads on mobile data';
@override
String get downloadSongLinkRegionDesc =>
'Used as userCountry for SongLink API lookup.';
@override
String get snackbarUnsupportedAudioFormat => 'Unsupported audio format';
@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';
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+622 -2
View File
@@ -356,7 +356,7 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
@override
String get artistAlbums => 'Albums';
@@ -525,6 +525,9 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get dialogImport => 'Import';
@override
String get dialogDownload => 'Download';
@override
String get dialogDiscard => 'Discard';
@@ -688,6 +691,17 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get errorNoTracksFound => 'No tracks found';
@override
String get errorUrlNotRecognized => 'Link not recognized';
@override
String get errorUrlNotRecognizedMessage =>
'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
@override
String get errorUrlFetchFailed =>
'Failed to load content from this link. Please try again.';
@override
String errorMissingExtensionSource(String item) {
return 'Cannot load $item: missing extension source';
@@ -761,6 +775,13 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get folderOrganizationNone => 'No organization';
@override
String get folderOrganizationByPlaylist => 'By Playlist';
@override
String get folderOrganizationByPlaylistSubtitle =>
'Separate folder for each playlist';
@override
String get folderOrganizationByArtist => 'By Artist';
@@ -1177,6 +1198,47 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get storeClearFilters => 'Clear filters';
@override
String get storeAddRepoTitle => 'Add Extension Repository';
@override
String get storeAddRepoDescription =>
'Enter a GitHub repository URL that contains a registry.json file to browse and install extensions.';
@override
String get storeRepoUrlLabel => 'Repository URL';
@override
String get storeRepoUrlHint => 'https://github.com/user/repo';
@override
String get storeRepoUrlHelper =>
'e.g. https://github.com/user/extensions-repo';
@override
String get storeAddRepoButton => 'Add Repository';
@override
String get storeChangeRepoTooltip => 'Change repository';
@override
String get storeRepoDialogTitle => 'Extension Repository';
@override
String get storeRepoDialogCurrent => 'Current repository:';
@override
String get storeNewRepoUrlLabel => 'New Repository URL';
@override
String get storeLoadError => 'Failed to load store';
@override
String get storeEmptyNoExtensions => 'No extensions available';
@override
String get storeEmptyNoResults => 'No extensions found';
@override
String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
@@ -1327,6 +1389,38 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@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 qualityNote =>
'Actual quality depends on track availability from the service';
@@ -1633,6 +1727,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';
@@ -1809,7 +1922,7 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get tutorialWelcomeTip2 =>
'Krijg FLAC-kwaliteit audio van Tidal, Qobuz of Deezer';
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
@override
String get tutorialWelcomeTip3 =>
@@ -2083,6 +2196,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';
@@ -2115,6 +2250,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...';
@@ -2126,6 +2273,54 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get trackConvertFailed => 'Conversion failed';
@override
String get cueSplitTitle => 'Split CUE Sheet';
@override
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
@override
String cueSplitAlbum(String album) {
return 'Album: $album';
}
@override
String cueSplitArtist(String artist) {
return 'Artist: $artist';
}
@override
String cueSplitTrackCount(int count) {
return '$count tracks';
}
@override
String get cueSplitConfirmTitle => 'Split CUE Album';
@override
String cueSplitConfirmMessage(String album, int count) {
return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
}
@override
String cueSplitSplitting(int current, int total) {
return 'Splitting CUE sheet... ($current/$total)';
}
@override
String cueSplitSuccess(int count) {
return 'Split into $count tracks successfully';
}
@override
String get cueSplitFailed => 'CUE split failed';
@override
String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
@override
String get cueSplitButton => 'Split into Tracks';
@override
String get actionCreate => 'Create';
@@ -2320,6 +2515,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...';
@@ -2342,4 +2548,418 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override
String get lyricsProvidersTitle => 'Lyrics Providers';
@override
String get lyricsProvidersDescription =>
'Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.';
@override
String get lyricsProvidersInfoText =>
'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.';
@override
String lyricsProvidersEnabledSection(int count) {
return 'Enabled ($count)';
}
@override
String lyricsProvidersDisabledSection(int count) {
return 'Disabled ($count)';
}
@override
String get lyricsProvidersAtLeastOne =>
'At least one provider must remain enabled';
@override
String get lyricsProvidersSaved => 'Lyrics provider priority saved';
@override
String get lyricsProvidersDiscardContent =>
'You have unsaved changes that will be lost.';
@override
String get lyricsProviderSpotifyApiDesc =>
'Spotify-sourced synced lyrics via community API';
@override
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
@override
String get lyricsProviderNeteaseDesc =>
'NetEase Cloud Music (good for Asian songs)';
@override
String get lyricsProviderMusixmatchDesc =>
'Largest lyrics database (multi-language)';
@override
String get lyricsProviderAppleMusicDesc =>
'Word-by-word synced lyrics (via proxy)';
@override
String get lyricsProviderQqMusicDesc =>
'QQ Music (good for Chinese songs, via proxy)';
@override
String get lyricsProviderExtensionDesc => 'Extension provider';
@override
String get safMigrationTitle => 'Storage Update Required';
@override
String get safMigrationMessage1 =>
'SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. This fixes \"permission denied\" errors on Android 10+.';
@override
String get safMigrationMessage2 =>
'Please select your download folder again to switch to the new storage system.';
@override
String get safMigrationSuccess => 'Download folder updated to SAF mode';
@override
String get settingsDonate => 'Donate';
@override
String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development';
@override
String get tooltipLoveAll => 'Love All';
@override
String get tooltipAddToPlaylist => 'Add to Playlist';
@override
String snackbarRemovedTracksFromLoved(int count) {
return 'Removed $count tracks from Loved';
}
@override
String snackbarAddedTracksToLoved(int count) {
return 'Added $count tracks to Loved';
}
@override
String get dialogDownloadAllTitle => 'Download All';
@override
String dialogDownloadAllMessage(int count) {
return 'Download $count tracks?';
}
@override
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
@override
String get homeGoToAlbum => 'Go to Album';
@override
String get homeAlbumInfoUnavailable => 'Album info not available';
@override
String get snackbarLoadingCueSheet => 'Loading CUE sheet...';
@override
String get snackbarMetadataSaved => 'Metadata saved successfully';
@override
String get snackbarFailedToEmbedLyrics => 'Failed to embed lyrics';
@override
String get snackbarFailedToWriteStorage => 'Failed to write back to storage';
@override
String snackbarError(String error) {
return 'Error: $error';
}
@override
String get snackbarNoActionDefined => 'No action defined for this button';
@override
String get noTracksFoundForAlbum => 'No tracks found for this album';
@override
String get downloadLocationSubtitle =>
'Choose storage mode for downloaded files.';
@override
String get storageModeAppFolder => 'App folder (non-SAF)';
@override
String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path';
@override
String get storageModeSaf => 'SAF folder';
@override
String get storageModeSafSubtitle =>
'Pick folder via Android Storage Access Framework';
@override
String get downloadFilenameDescription =>
'Customize how your files are named.';
@override
String get downloadFilenameInsertTag => 'Tap to insert tag:';
@override
String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders';
@override
String get downloadSeparateSinglesDisabled => 'All files in same structure';
@override
String get downloadArtistNameFilters => 'Artist Name Filters';
@override
String get downloadCreatePlaylistSourceFolder =>
'Create playlist source folder';
@override
String get downloadCreatePlaylistSourceFolderEnabled =>
'Playlist downloads use Playlist/ plus your normal folder structure.';
@override
String get downloadCreatePlaylistSourceFolderDisabled =>
'Playlist downloads use the normal folder structure only.';
@override
String get downloadCreatePlaylistSourceFolderRedundant =>
'By Playlist already places downloads inside a playlist folder.';
@override
String get downloadSongLinkRegion => 'SongLink Region';
@override
String get downloadNetworkCompatibilityMode => 'Network compatibility mode';
@override
String get downloadNetworkCompatibilityModeEnabled =>
'Enabled: try HTTP + accept invalid TLS certificates (unsafe)';
@override
String get downloadNetworkCompatibilityModeDisabled =>
'Off: strict HTTPS certificate validation (recommended)';
@override
String get downloadSelectServiceToEnable =>
'Select a built-in service to enable';
@override
String get downloadSelectTidalQobuz =>
'Select Tidal or Qobuz above to configure quality';
@override
String get downloadEmbedLyricsDisabled =>
'Disabled while Embed Metadata is turned off';
@override
String get downloadNeteaseIncludeTranslation =>
'Netease: Include Translation';
@override
String get downloadNeteaseIncludeTranslationEnabled =>
'Append translated lyrics when available';
@override
String get downloadNeteaseIncludeTranslationDisabled =>
'Use original lyrics only';
@override
String get downloadNeteaseIncludeRomanization =>
'Netease: Include Romanization';
@override
String get downloadNeteaseIncludeRomanizationEnabled =>
'Append romanized lyrics when available';
@override
String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled';
@override
String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word';
@override
String get downloadAppleQqMultiPersonEnabled =>
'Enable v1/v2 speaker and [bg:] tags';
@override
String get downloadAppleQqMultiPersonDisabled =>
'Simplified word-by-word formatting';
@override
String get downloadMusixmatchLanguage => 'Musixmatch Language';
@override
String get downloadMusixmatchLanguageAuto => 'Auto (original)';
@override
String get downloadFilterContributing =>
'Filter contributing artists in Album Artist';
@override
String get downloadFilterContributingEnabled =>
'Album Artist metadata uses primary artist only';
@override
String get downloadFilterContributingDisabled =>
'Keep full Album Artist metadata value';
@override
String get downloadProvidersNoneEnabled => 'None enabled';
@override
String get downloadMusixmatchLanguageCode => 'Language code';
@override
String get downloadMusixmatchLanguageHint => 'auto / en / es / ja';
@override
String get downloadMusixmatchLanguageDesc =>
'Set preferred language code (example: en, es, ja). Leave empty for auto.';
@override
String get downloadMusixmatchAuto => 'Auto';
@override
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
@override
String get downloadNetworkWifiOnlySubtitle =>
'Pause downloads on mobile data';
@override
String get downloadSongLinkRegionDesc =>
'Used as userCountry for SongLink API lookup.';
@override
String get snackbarUnsupportedAudioFormat => 'Unsupported audio format';
@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';
}
+622 -1
View File
@@ -525,6 +525,9 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get dialogImport => 'Import';
@override
String get dialogDownload => 'Download';
@override
String get dialogDiscard => 'Discard';
@@ -688,6 +691,17 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get errorNoTracksFound => 'No tracks found';
@override
String get errorUrlNotRecognized => 'Link not recognized';
@override
String get errorUrlNotRecognizedMessage =>
'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
@override
String get errorUrlFetchFailed =>
'Failed to load content from this link. Please try again.';
@override
String errorMissingExtensionSource(String item) {
return 'Cannot load $item: missing extension source';
@@ -761,6 +775,13 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get folderOrganizationNone => 'No organization';
@override
String get folderOrganizationByPlaylist => 'By Playlist';
@override
String get folderOrganizationByPlaylistSubtitle =>
'Separate folder for each playlist';
@override
String get folderOrganizationByArtist => 'By Artist';
@@ -1177,6 +1198,47 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get storeClearFilters => 'Clear filters';
@override
String get storeAddRepoTitle => 'Add Extension Repository';
@override
String get storeAddRepoDescription =>
'Enter a GitHub repository URL that contains a registry.json file to browse and install extensions.';
@override
String get storeRepoUrlLabel => 'Repository URL';
@override
String get storeRepoUrlHint => 'https://github.com/user/repo';
@override
String get storeRepoUrlHelper =>
'e.g. https://github.com/user/extensions-repo';
@override
String get storeAddRepoButton => 'Add Repository';
@override
String get storeChangeRepoTooltip => 'Change repository';
@override
String get storeRepoDialogTitle => 'Extension Repository';
@override
String get storeRepoDialogCurrent => 'Current repository:';
@override
String get storeNewRepoUrlLabel => 'New Repository URL';
@override
String get storeLoadError => 'Failed to load store';
@override
String get storeEmptyNoExtensions => 'No extensions available';
@override
String get storeEmptyNoResults => 'No extensions found';
@override
String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
@@ -1327,6 +1389,38 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@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 qualityNote =>
'Actual quality depends on track availability from the service';
@@ -1633,6 +1727,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';
@@ -2083,6 +2196,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';
@@ -2092,7 +2227,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';
@@ -2115,6 +2251,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...';
@@ -2126,6 +2274,54 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get trackConvertFailed => 'Conversion failed';
@override
String get cueSplitTitle => 'Split CUE Sheet';
@override
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
@override
String cueSplitAlbum(String album) {
return 'Album: $album';
}
@override
String cueSplitArtist(String artist) {
return 'Artist: $artist';
}
@override
String cueSplitTrackCount(int count) {
return '$count tracks';
}
@override
String get cueSplitConfirmTitle => 'Split CUE Album';
@override
String cueSplitConfirmMessage(String album, int count) {
return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
}
@override
String cueSplitSplitting(int current, int total) {
return 'Splitting CUE sheet... ($current/$total)';
}
@override
String cueSplitSuccess(int count) {
return 'Split into $count tracks successfully';
}
@override
String get cueSplitFailed => 'CUE split failed';
@override
String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
@override
String get cueSplitButton => 'Split into Tracks';
@override
String get actionCreate => 'Create';
@@ -2320,6 +2516,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...';
@@ -2342,6 +2549,420 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override
String get lyricsProvidersTitle => 'Lyrics Providers';
@override
String get lyricsProvidersDescription =>
'Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.';
@override
String get lyricsProvidersInfoText =>
'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.';
@override
String lyricsProvidersEnabledSection(int count) {
return 'Enabled ($count)';
}
@override
String lyricsProvidersDisabledSection(int count) {
return 'Disabled ($count)';
}
@override
String get lyricsProvidersAtLeastOne =>
'At least one provider must remain enabled';
@override
String get lyricsProvidersSaved => 'Lyrics provider priority saved';
@override
String get lyricsProvidersDiscardContent =>
'You have unsaved changes that will be lost.';
@override
String get lyricsProviderSpotifyApiDesc =>
'Spotify-sourced synced lyrics via community API';
@override
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
@override
String get lyricsProviderNeteaseDesc =>
'NetEase Cloud Music (good for Asian songs)';
@override
String get lyricsProviderMusixmatchDesc =>
'Largest lyrics database (multi-language)';
@override
String get lyricsProviderAppleMusicDesc =>
'Word-by-word synced lyrics (via proxy)';
@override
String get lyricsProviderQqMusicDesc =>
'QQ Music (good for Chinese songs, via proxy)';
@override
String get lyricsProviderExtensionDesc => 'Extension provider';
@override
String get safMigrationTitle => 'Storage Update Required';
@override
String get safMigrationMessage1 =>
'SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. This fixes \"permission denied\" errors on Android 10+.';
@override
String get safMigrationMessage2 =>
'Please select your download folder again to switch to the new storage system.';
@override
String get safMigrationSuccess => 'Download folder updated to SAF mode';
@override
String get settingsDonate => 'Donate';
@override
String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development';
@override
String get tooltipLoveAll => 'Love All';
@override
String get tooltipAddToPlaylist => 'Add to Playlist';
@override
String snackbarRemovedTracksFromLoved(int count) {
return 'Removed $count tracks from Loved';
}
@override
String snackbarAddedTracksToLoved(int count) {
return 'Added $count tracks to Loved';
}
@override
String get dialogDownloadAllTitle => 'Download All';
@override
String dialogDownloadAllMessage(int count) {
return 'Download $count tracks?';
}
@override
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
@override
String get homeGoToAlbum => 'Go to Album';
@override
String get homeAlbumInfoUnavailable => 'Album info not available';
@override
String get snackbarLoadingCueSheet => 'Loading CUE sheet...';
@override
String get snackbarMetadataSaved => 'Metadata saved successfully';
@override
String get snackbarFailedToEmbedLyrics => 'Failed to embed lyrics';
@override
String get snackbarFailedToWriteStorage => 'Failed to write back to storage';
@override
String snackbarError(String error) {
return 'Error: $error';
}
@override
String get snackbarNoActionDefined => 'No action defined for this button';
@override
String get noTracksFoundForAlbum => 'No tracks found for this album';
@override
String get downloadLocationSubtitle =>
'Choose storage mode for downloaded files.';
@override
String get storageModeAppFolder => 'App folder (non-SAF)';
@override
String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path';
@override
String get storageModeSaf => 'SAF folder';
@override
String get storageModeSafSubtitle =>
'Pick folder via Android Storage Access Framework';
@override
String get downloadFilenameDescription =>
'Customize how your files are named.';
@override
String get downloadFilenameInsertTag => 'Tap to insert tag:';
@override
String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders';
@override
String get downloadSeparateSinglesDisabled => 'All files in same structure';
@override
String get downloadArtistNameFilters => 'Artist Name Filters';
@override
String get downloadCreatePlaylistSourceFolder =>
'Create playlist source folder';
@override
String get downloadCreatePlaylistSourceFolderEnabled =>
'Playlist downloads use Playlist/ plus your normal folder structure.';
@override
String get downloadCreatePlaylistSourceFolderDisabled =>
'Playlist downloads use the normal folder structure only.';
@override
String get downloadCreatePlaylistSourceFolderRedundant =>
'By Playlist already places downloads inside a playlist folder.';
@override
String get downloadSongLinkRegion => 'SongLink Region';
@override
String get downloadNetworkCompatibilityMode => 'Network compatibility mode';
@override
String get downloadNetworkCompatibilityModeEnabled =>
'Enabled: try HTTP + accept invalid TLS certificates (unsafe)';
@override
String get downloadNetworkCompatibilityModeDisabled =>
'Off: strict HTTPS certificate validation (recommended)';
@override
String get downloadSelectServiceToEnable =>
'Select a built-in service to enable';
@override
String get downloadSelectTidalQobuz =>
'Select Tidal or Qobuz above to configure quality';
@override
String get downloadEmbedLyricsDisabled =>
'Disabled while Embed Metadata is turned off';
@override
String get downloadNeteaseIncludeTranslation =>
'Netease: Include Translation';
@override
String get downloadNeteaseIncludeTranslationEnabled =>
'Append translated lyrics when available';
@override
String get downloadNeteaseIncludeTranslationDisabled =>
'Use original lyrics only';
@override
String get downloadNeteaseIncludeRomanization =>
'Netease: Include Romanization';
@override
String get downloadNeteaseIncludeRomanizationEnabled =>
'Append romanized lyrics when available';
@override
String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled';
@override
String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word';
@override
String get downloadAppleQqMultiPersonEnabled =>
'Enable v1/v2 speaker and [bg:] tags';
@override
String get downloadAppleQqMultiPersonDisabled =>
'Simplified word-by-word formatting';
@override
String get downloadMusixmatchLanguage => 'Musixmatch Language';
@override
String get downloadMusixmatchLanguageAuto => 'Auto (original)';
@override
String get downloadFilterContributing =>
'Filter contributing artists in Album Artist';
@override
String get downloadFilterContributingEnabled =>
'Album Artist metadata uses primary artist only';
@override
String get downloadFilterContributingDisabled =>
'Keep full Album Artist metadata value';
@override
String get downloadProvidersNoneEnabled => 'None enabled';
@override
String get downloadMusixmatchLanguageCode => 'Language code';
@override
String get downloadMusixmatchLanguageHint => 'auto / en / es / ja';
@override
String get downloadMusixmatchLanguageDesc =>
'Set preferred language code (example: en, es, ja). Leave empty for auto.';
@override
String get downloadMusixmatchAuto => 'Auto';
@override
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
@override
String get downloadNetworkWifiOnlySubtitle =>
'Pause downloads on mobile data';
@override
String get downloadSongLinkRegionDesc =>
'Used as userCountry for SongLink API lookup.';
@override
String get snackbarUnsupportedAudioFormat => 'Unsupported audio format';
@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`).
File diff suppressed because it is too large Load Diff
+620
View File
@@ -530,6 +530,9 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get dialogImport => 'İçe aktar';
@override
String get dialogDownload => 'Download';
@override
String get dialogDiscard => 'Vazgeç';
@@ -693,6 +696,17 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get errorNoTracksFound => 'Parça bulunamadı';
@override
String get errorUrlNotRecognized => 'Link not recognized';
@override
String get errorUrlNotRecognizedMessage =>
'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
@override
String get errorUrlFetchFailed =>
'Failed to load content from this link. Please try again.';
@override
String errorMissingExtensionSource(String item) {
return '$item yüklenemedi: Eksik eklenti kaynağı';
@@ -766,6 +780,13 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get folderOrganizationNone => 'Organizasyon yok';
@override
String get folderOrganizationByPlaylist => 'By Playlist';
@override
String get folderOrganizationByPlaylistSubtitle =>
'Separate folder for each playlist';
@override
String get folderOrganizationByArtist => 'Sanatçıya Göre';
@@ -1188,6 +1209,47 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get storeClearFilters => 'Filtreleri temizle';
@override
String get storeAddRepoTitle => 'Add Extension Repository';
@override
String get storeAddRepoDescription =>
'Enter a GitHub repository URL that contains a registry.json file to browse and install extensions.';
@override
String get storeRepoUrlLabel => 'Repository URL';
@override
String get storeRepoUrlHint => 'https://github.com/user/repo';
@override
String get storeRepoUrlHelper =>
'e.g. https://github.com/user/extensions-repo';
@override
String get storeAddRepoButton => 'Add Repository';
@override
String get storeChangeRepoTooltip => 'Change repository';
@override
String get storeRepoDialogTitle => 'Extension Repository';
@override
String get storeRepoDialogCurrent => 'Current repository:';
@override
String get storeNewRepoUrlLabel => 'New Repository URL';
@override
String get storeLoadError => 'Failed to load store';
@override
String get storeEmptyNoExtensions => 'No extensions available';
@override
String get storeEmptyNoResults => 'No extensions found';
@override
String get extensionDefaultProvider => 'Varsayılan (Deezer/Spotify)';
@@ -1339,6 +1401,38 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@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 qualityNote =>
'Actual quality depends on track availability from the service';
@@ -1645,6 +1739,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';
@@ -2095,6 +2208,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';
@@ -2127,6 +2262,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...';
@@ -2138,6 +2285,54 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get trackConvertFailed => 'Conversion failed';
@override
String get cueSplitTitle => 'Split CUE Sheet';
@override
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
@override
String cueSplitAlbum(String album) {
return 'Album: $album';
}
@override
String cueSplitArtist(String artist) {
return 'Artist: $artist';
}
@override
String cueSplitTrackCount(int count) {
return '$count tracks';
}
@override
String get cueSplitConfirmTitle => 'Split CUE Album';
@override
String cueSplitConfirmMessage(String album, int count) {
return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
}
@override
String cueSplitSplitting(int current, int total) {
return 'Splitting CUE sheet... ($current/$total)';
}
@override
String cueSplitSuccess(int count) {
return 'Split into $count tracks successfully';
}
@override
String get cueSplitFailed => 'CUE split failed';
@override
String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
@override
String get cueSplitButton => 'Split into Tracks';
@override
String get actionCreate => 'Create';
@@ -2332,6 +2527,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...';
@@ -2354,4 +2560,418 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override
String get lyricsProvidersTitle => 'Lyrics Providers';
@override
String get lyricsProvidersDescription =>
'Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.';
@override
String get lyricsProvidersInfoText =>
'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.';
@override
String lyricsProvidersEnabledSection(int count) {
return 'Enabled ($count)';
}
@override
String lyricsProvidersDisabledSection(int count) {
return 'Disabled ($count)';
}
@override
String get lyricsProvidersAtLeastOne =>
'At least one provider must remain enabled';
@override
String get lyricsProvidersSaved => 'Lyrics provider priority saved';
@override
String get lyricsProvidersDiscardContent =>
'You have unsaved changes that will be lost.';
@override
String get lyricsProviderSpotifyApiDesc =>
'Spotify-sourced synced lyrics via community API';
@override
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
@override
String get lyricsProviderNeteaseDesc =>
'NetEase Cloud Music (good for Asian songs)';
@override
String get lyricsProviderMusixmatchDesc =>
'Largest lyrics database (multi-language)';
@override
String get lyricsProviderAppleMusicDesc =>
'Word-by-word synced lyrics (via proxy)';
@override
String get lyricsProviderQqMusicDesc =>
'QQ Music (good for Chinese songs, via proxy)';
@override
String get lyricsProviderExtensionDesc => 'Extension provider';
@override
String get safMigrationTitle => 'Storage Update Required';
@override
String get safMigrationMessage1 =>
'SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. This fixes \"permission denied\" errors on Android 10+.';
@override
String get safMigrationMessage2 =>
'Please select your download folder again to switch to the new storage system.';
@override
String get safMigrationSuccess => 'Download folder updated to SAF mode';
@override
String get settingsDonate => 'Donate';
@override
String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development';
@override
String get tooltipLoveAll => 'Love All';
@override
String get tooltipAddToPlaylist => 'Add to Playlist';
@override
String snackbarRemovedTracksFromLoved(int count) {
return 'Removed $count tracks from Loved';
}
@override
String snackbarAddedTracksToLoved(int count) {
return 'Added $count tracks to Loved';
}
@override
String get dialogDownloadAllTitle => 'Download All';
@override
String dialogDownloadAllMessage(int count) {
return 'Download $count tracks?';
}
@override
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
@override
String get homeGoToAlbum => 'Go to Album';
@override
String get homeAlbumInfoUnavailable => 'Album info not available';
@override
String get snackbarLoadingCueSheet => 'Loading CUE sheet...';
@override
String get snackbarMetadataSaved => 'Metadata saved successfully';
@override
String get snackbarFailedToEmbedLyrics => 'Failed to embed lyrics';
@override
String get snackbarFailedToWriteStorage => 'Failed to write back to storage';
@override
String snackbarError(String error) {
return 'Error: $error';
}
@override
String get snackbarNoActionDefined => 'No action defined for this button';
@override
String get noTracksFoundForAlbum => 'No tracks found for this album';
@override
String get downloadLocationSubtitle =>
'Choose storage mode for downloaded files.';
@override
String get storageModeAppFolder => 'App folder (non-SAF)';
@override
String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path';
@override
String get storageModeSaf => 'SAF folder';
@override
String get storageModeSafSubtitle =>
'Pick folder via Android Storage Access Framework';
@override
String get downloadFilenameDescription =>
'Customize how your files are named.';
@override
String get downloadFilenameInsertTag => 'Tap to insert tag:';
@override
String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders';
@override
String get downloadSeparateSinglesDisabled => 'All files in same structure';
@override
String get downloadArtistNameFilters => 'Artist Name Filters';
@override
String get downloadCreatePlaylistSourceFolder =>
'Create playlist source folder';
@override
String get downloadCreatePlaylistSourceFolderEnabled =>
'Playlist downloads use Playlist/ plus your normal folder structure.';
@override
String get downloadCreatePlaylistSourceFolderDisabled =>
'Playlist downloads use the normal folder structure only.';
@override
String get downloadCreatePlaylistSourceFolderRedundant =>
'By Playlist already places downloads inside a playlist folder.';
@override
String get downloadSongLinkRegion => 'SongLink Region';
@override
String get downloadNetworkCompatibilityMode => 'Network compatibility mode';
@override
String get downloadNetworkCompatibilityModeEnabled =>
'Enabled: try HTTP + accept invalid TLS certificates (unsafe)';
@override
String get downloadNetworkCompatibilityModeDisabled =>
'Off: strict HTTPS certificate validation (recommended)';
@override
String get downloadSelectServiceToEnable =>
'Select a built-in service to enable';
@override
String get downloadSelectTidalQobuz =>
'Select Tidal or Qobuz above to configure quality';
@override
String get downloadEmbedLyricsDisabled =>
'Disabled while Embed Metadata is turned off';
@override
String get downloadNeteaseIncludeTranslation =>
'Netease: Include Translation';
@override
String get downloadNeteaseIncludeTranslationEnabled =>
'Append translated lyrics when available';
@override
String get downloadNeteaseIncludeTranslationDisabled =>
'Use original lyrics only';
@override
String get downloadNeteaseIncludeRomanization =>
'Netease: Include Romanization';
@override
String get downloadNeteaseIncludeRomanizationEnabled =>
'Append romanized lyrics when available';
@override
String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled';
@override
String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word';
@override
String get downloadAppleQqMultiPersonEnabled =>
'Enable v1/v2 speaker and [bg:] tags';
@override
String get downloadAppleQqMultiPersonDisabled =>
'Simplified word-by-word formatting';
@override
String get downloadMusixmatchLanguage => 'Musixmatch Language';
@override
String get downloadMusixmatchLanguageAuto => 'Auto (original)';
@override
String get downloadFilterContributing =>
'Filter contributing artists in Album Artist';
@override
String get downloadFilterContributingEnabled =>
'Album Artist metadata uses primary artist only';
@override
String get downloadFilterContributingDisabled =>
'Keep full Album Artist metadata value';
@override
String get downloadProvidersNoneEnabled => 'None enabled';
@override
String get downloadMusixmatchLanguageCode => 'Language code';
@override
String get downloadMusixmatchLanguageHint => 'auto / en / es / ja';
@override
String get downloadMusixmatchLanguageDesc =>
'Set preferred language code (example: en, es, ja). Leave empty for auto.';
@override
String get downloadMusixmatchAuto => 'Auto';
@override
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
@override
String get downloadNetworkWifiOnlySubtitle =>
'Pause downloads on mobile data';
@override
String get downloadSongLinkRegionDesc =>
'Used as userCountry for SongLink API lookup.';
@override
String get snackbarUnsupportedAudioFormat => 'Unsupported audio format';
@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';
}
File diff suppressed because it is too large Load Diff
+9 -9
View File
@@ -728,7 +728,7 @@
"@dialogDeleteSelectedTitle": {
"description": "Dialog title - delete selected items"
},
"dialogDeleteSelectedMessage": "Lösche {count} {count, plural, one {}=1{Track} other{Tracks}} aus dem Verlauf?\n\nDies löscht auch die Dateien aus dem Speicher.",
"dialogDeleteSelectedMessage": "Lösche {count} {count, plural, one {Track} other{Tracks}} aus dem Verlauf?\n\nDies löscht auch die Dateien aus dem Speicher.",
"@dialogDeleteSelectedMessage": {
"description": "Dialog message - delete selected tracks",
"placeholders": {
@@ -807,7 +807,7 @@
"@snackbarCredentialsCleared": {
"description": "Snackbar - Spotify credentials removed"
},
"snackbarDeletedTracks": "{count} {count, plural, one {}=1{Titel} other{Titel}}",
"snackbarDeletedTracks": "{count} {count, plural, one {Titel} other{Titel}}",
"@snackbarDeletedTracks": {
"description": "Snackbar - tracks deleted",
"placeholders": {
@@ -1909,7 +1909,7 @@
"@downloadedAlbumDeleteSelected": {
"description": "Button - delete selected tracks"
},
"downloadedAlbumDeleteMessage": "{count} {count, plural, one {}=1{Titel} other{Titel}} aus diesem Album löschen?\n\nDadurch werden auch die Dateien aus dem Speicher gelöscht.",
"downloadedAlbumDeleteMessage": "{count} {count, plural, one {Titel} other{Titel}} aus diesem Album löschen?\n\nDadurch werden auch die Dateien aus dem Speicher gelöscht.",
"@downloadedAlbumDeleteMessage": {
"description": "Delete confirmation with count",
"placeholders": {
@@ -1935,7 +1935,7 @@
"@downloadedAlbumTapToSelect": {
"description": "Selection hint"
},
"downloadedAlbumDeleteCount": "Lösche {count} {count, plural, one {}=1{Titel}other{Titel}}",
"downloadedAlbumDeleteCount": "Lösche {count} {count, plural, one {Titel} other{Titel}}",
"@downloadedAlbumDeleteCount": {
"description": "Delete button text with count",
"placeholders": {
@@ -2373,7 +2373,7 @@
"@timeJustNow": {
"description": "Relative time - less than a minute ago"
},
"timeMinutesAgo": "{count, plural, one {}=1{vor 1 Minute} other{vor {count} Minuten}}",
"timeMinutesAgo": "{count, plural, one {vor {count} Minute} other{vor {count} Minuten}}",
"@timeMinutesAgo": {
"description": "Relative time - minutes ago",
"placeholders": {
@@ -2382,7 +2382,7 @@
}
}
},
"timeHoursAgo": "{count, plural, one {}=1{vor 1 Stunde} other{vor {count} Stunden}}",
"timeHoursAgo": "{count, plural, one {vor {count} Stunde} other{vor {count} Stunden}}",
"@timeHoursAgo": {
"description": "Relative time - hours ago",
"placeholders": {
@@ -3117,7 +3117,7 @@
"@collectionPlaylistRemoveCover": {
"description": "Bottom sheet action to remove custom cover image from a playlist"
},
"selectionShareCount": "Teile {count} {count, plural, one {}=1{Titel}other{Titel}}",
"selectionShareCount": "Teile {count} {count, plural, one {Titel} other{Titel}}",
"@selectionShareCount": {
"description": "Share button text with count in selection mode",
"placeholders": {
@@ -3130,7 +3130,7 @@
"@selectionShareNoFiles": {
"description": "Snackbar when no selected files exist on disk"
},
"selectionConvertCount": "Konvertiere {count} {count, plural, one {}=1{Titel}other{Titel}}",
"selectionConvertCount": "Konvertiere {count} {count, plural, one {Titel} other{Titel}}",
"@selectionConvertCount": {
"description": "Convert button text with count in selection mode",
"placeholders": {
@@ -3147,7 +3147,7 @@
"@selectionBatchConvertConfirmTitle": {
"description": "Confirmation dialog title for batch conversion"
},
"selectionBatchConvertConfirmMessage": "Konvertiere {count} {format} {count, plural, one {}=1{Titel} other{Titel}} zu {bitrate}?\n\nOriginaldateien werden nach der Konvertierung gelöscht.",
"selectionBatchConvertConfirmMessage": "Konvertiere {count} {format} {count, plural, one {Titel} other{Titel}} zu {bitrate}?\n\nOriginaldateien werden nach der Konvertierung gelöscht.",
"@selectionBatchConvertConfirmMessage": {
"description": "Confirmation dialog message for batch conversion",
"placeholders": {
+700 -2
View File
@@ -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"
@@ -1570,7 +1574,59 @@
"@storeClearFilters": {
"description": "Button to clear all filters"
},
"extensionDefaultProvider": "Default (Deezer/Spotify)",
"storeAddRepoTitle": "Add Extension Repository",
"@storeAddRepoTitle": {
"description": "Store setup screen - heading when no repo is configured"
},
"storeAddRepoDescription": "Enter a GitHub repository URL that contains a registry.json file to browse and install extensions.",
"@storeAddRepoDescription": {
"description": "Store setup screen - explanatory text"
},
"storeRepoUrlLabel": "Repository URL",
"@storeRepoUrlLabel": {
"description": "Label for the repository URL input field"
},
"storeRepoUrlHint": "https://github.com/user/repo",
"@storeRepoUrlHint": {
"description": "Hint/placeholder for the repository URL input field"
},
"storeRepoUrlHelper": "e.g. https://github.com/user/extensions-repo",
"@storeRepoUrlHelper": {
"description": "Helper text below the repository URL input field"
},
"storeAddRepoButton": "Add Repository",
"@storeAddRepoButton": {
"description": "Button to submit a new repository URL"
},
"storeChangeRepoTooltip": "Change repository",
"@storeChangeRepoTooltip": {
"description": "Tooltip for the change-repository icon button in the app bar"
},
"storeRepoDialogTitle": "Extension Repository",
"@storeRepoDialogTitle": {
"description": "Title of the change/remove repository dialog"
},
"storeRepoDialogCurrent": "Current repository:",
"@storeRepoDialogCurrent": {
"description": "Label shown above the current repository URL in the dialog"
},
"storeNewRepoUrlLabel": "New Repository URL",
"@storeNewRepoUrlLabel": {
"description": "Label for the new repository URL field inside the dialog"
},
"storeLoadError": "Failed to load store",
"@storeLoadError": {
"description": "Error heading when the store cannot be loaded"
},
"storeEmptyNoExtensions": "No extensions available",
"@storeEmptyNoExtensions": {
"description": "Message when store has no extensions"
},
"storeEmptyNoResults": "No extensions found",
"@storeEmptyNoResults": {
"description": "Message when search/filter returns no results"
},
"extensionDefaultProvider": "Default (Deezer)",
"@extensionDefaultProvider": {
"description": "Default search provider option"
},
@@ -1769,6 +1825,46 @@
"@qualityHiResFlacMaxSubtitle": {
"description": "Technical spec for hi-res max"
},
"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"
},
"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 Tidal lossy 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 Tidal lossy option"
},
"downloadLossyOpus128": "Opus 128kbps",
"@downloadLossyOpus128": {
"description": "Tidal lossy format option - Opus 128kbps"
},
"downloadLossyOpus128Subtitle": "Smallest size, ~4MB per track",
"@downloadLossyOpus128Subtitle": {
"description": "Subtitle for Opus 128kbps Tidal lossy option"
},
"qualityNote": "Actual quality depends on track availability from the service",
"@qualityNote": {
"description": "Note about quality availability"
@@ -2186,6 +2282,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"
@@ -2763,6 +2883,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",
@@ -2776,7 +2937,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"
},
@@ -2811,6 +2972,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"
@@ -3162,6 +3339,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",
@@ -3205,5 +3394,514 @@
"downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only",
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
"description": "Subtitle when Track Artist is used for folder naming"
},
"lyricsProvidersTitle": "Lyrics Providers",
"@lyricsProvidersTitle": {
"description": "Title for the lyrics provider priority page"
},
"lyricsProvidersDescription": "Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.",
"@lyricsProvidersDescription": {
"description": "Description on the lyrics provider priority page"
},
"lyricsProvidersInfoText": "Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.",
"@lyricsProvidersInfoText": {
"description": "Info tip on lyrics provider priority page"
},
"lyricsProvidersEnabledSection": "Enabled ({count})",
"@lyricsProvidersEnabledSection": {
"description": "Section header for enabled providers",
"placeholders": {
"count": {
"type": "int"
}
}
},
"lyricsProvidersDisabledSection": "Disabled ({count})",
"@lyricsProvidersDisabledSection": {
"description": "Section header for disabled providers",
"placeholders": {
"count": {
"type": "int"
}
}
},
"lyricsProvidersAtLeastOne": "At least one provider must remain enabled",
"@lyricsProvidersAtLeastOne": {
"description": "Snackbar when user tries to disable the last enabled provider"
},
"lyricsProvidersSaved": "Lyrics provider priority saved",
"@lyricsProvidersSaved": {
"description": "Snackbar after saving lyrics provider priority"
},
"lyricsProvidersDiscardContent": "You have unsaved changes that will be lost.",
"@lyricsProvidersDiscardContent": {
"description": "Body text of the discard-changes dialog on lyrics provider page"
},
"lyricsProviderSpotifyApiDesc": "Spotify-sourced synced lyrics via community API",
"@lyricsProviderSpotifyApiDesc": {
"description": "Description for Spotify Lyrics API provider"
},
"lyricsProviderLrclibDesc": "Open-source synced lyrics database",
"@lyricsProviderLrclibDesc": {
"description": "Description for LRCLIB provider"
},
"lyricsProviderNeteaseDesc": "NetEase Cloud Music (good for Asian songs)",
"@lyricsProviderNeteaseDesc": {
"description": "Description for Netease provider"
},
"lyricsProviderMusixmatchDesc": "Largest lyrics database (multi-language)",
"@lyricsProviderMusixmatchDesc": {
"description": "Description for Musixmatch provider"
},
"lyricsProviderAppleMusicDesc": "Word-by-word synced lyrics (via proxy)",
"@lyricsProviderAppleMusicDesc": {
"description": "Description for Apple Music provider"
},
"lyricsProviderQqMusicDesc": "QQ Music (good for Chinese songs, via proxy)",
"@lyricsProviderQqMusicDesc": {
"description": "Description for QQ Music provider"
},
"lyricsProviderExtensionDesc": "Extension provider",
"@lyricsProviderExtensionDesc": {
"description": "Generic description for extension-based lyrics providers"
},
"safMigrationTitle": "Storage Update Required",
"@safMigrationTitle": {
"description": "Title of SAF migration dialog"
},
"safMigrationMessage1": "SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. This fixes \"permission denied\" errors on Android 10+.",
"@safMigrationMessage1": {
"description": "First paragraph of SAF migration dialog"
},
"safMigrationMessage2": "Please select your download folder again to switch to the new storage system.",
"@safMigrationMessage2": {
"description": "Second paragraph of SAF migration dialog"
},
"safMigrationSuccess": "Download folder updated to SAF mode",
"@safMigrationSuccess": {
"description": "Snackbar after successfully migrating to SAF"
},
"settingsDonate": "Donate",
"@settingsDonate": {
"description": "Settings menu item - donate"
},
"settingsDonateSubtitle": "Support SpotiFLAC-Mobile development",
"@settingsDonateSubtitle": {
"description": "Subtitle for donate menu item"
},
"tooltipLoveAll": "Love All",
"@tooltipLoveAll": {
"description": "Tooltip for the Love All button on album/playlist screens"
},
"tooltipAddToPlaylist": "Add to Playlist",
"@tooltipAddToPlaylist": {
"description": "Tooltip for the Add to Playlist button"
},
"snackbarRemovedTracksFromLoved": "Removed {count} tracks from Loved",
"@snackbarRemovedTracksFromLoved": {
"description": "Snackbar after removing multiple tracks from Loved folder",
"placeholders": {
"count": {
"type": "int"
}
}
},
"snackbarAddedTracksToLoved": "Added {count} tracks to Loved",
"@snackbarAddedTracksToLoved": {
"description": "Snackbar after adding multiple tracks to Loved folder",
"placeholders": {
"count": {
"type": "int"
}
}
},
"dialogDownloadAllTitle": "Download All",
"@dialogDownloadAllTitle": {
"description": "Title of the Download All confirmation dialog"
},
"dialogDownloadAllMessage": "Download {count} tracks?",
"@dialogDownloadAllMessage": {
"description": "Body of the Download All confirmation dialog",
"placeholders": {
"count": {
"type": "int"
}
}
},
"dialogDownload": "Download",
"@dialogDownload": {
"description": "Confirm button in Download All dialog"
},
"homeSkipAlreadyDownloaded": "Skip already downloaded songs",
"@homeSkipAlreadyDownloaded": {
"description": "Checkbox label in import dialog to skip already-downloaded songs"
},
"homeGoToAlbum": "Go to Album",
"@homeGoToAlbum": {
"description": "Context menu item to navigate to the album page"
},
"homeAlbumInfoUnavailable": "Album info not available",
"@homeAlbumInfoUnavailable": {
"description": "Snackbar when album info cannot be loaded"
},
"snackbarLoadingCueSheet": "Loading CUE sheet...",
"@snackbarLoadingCueSheet": {
"description": "Snackbar while loading a CUE sheet file"
},
"snackbarMetadataSaved": "Metadata saved successfully",
"@snackbarMetadataSaved": {
"description": "Snackbar after successfully saving track metadata"
},
"snackbarFailedToEmbedLyrics": "Failed to embed lyrics",
"@snackbarFailedToEmbedLyrics": {
"description": "Snackbar when lyrics embedding fails"
},
"snackbarFailedToWriteStorage": "Failed to write back to storage",
"@snackbarFailedToWriteStorage": {
"description": "Snackbar when writing metadata back to file fails"
},
"snackbarError": "Error: {error}",
"@snackbarError": {
"description": "Generic error snackbar with error detail",
"placeholders": {
"error": {
"type": "String"
}
}
},
"snackbarNoActionDefined": "No action defined for this button",
"@snackbarNoActionDefined": {
"description": "Snackbar when an extension button has no action configured"
},
"noTracksFoundForAlbum": "No tracks found for this album",
"@noTracksFoundForAlbum": {
"description": "Empty state message when an album has no tracks"
},
"downloadLocationSubtitle": "Choose storage mode for downloaded files.",
"@downloadLocationSubtitle": {
"description": "Subtitle text in Android download location bottom sheet"
},
"storageModeAppFolder": "App folder (non-SAF)",
"@storageModeAppFolder": {
"description": "Storage mode option - use legacy app folder"
},
"storageModeAppFolderSubtitle": "Use default Music/SpotiFLAC path",
"@storageModeAppFolderSubtitle": {
"description": "Subtitle for app folder storage mode"
},
"storageModeSaf": "SAF folder",
"@storageModeSaf": {
"description": "Storage mode option - use Android SAF picker"
},
"storageModeSafSubtitle": "Pick folder via Android Storage Access Framework",
"@storageModeSafSubtitle": {
"description": "Subtitle for SAF storage mode"
},
"downloadFilenameDescription": "Customize how your files are named.",
"@downloadFilenameDescription": {
"description": "Description text in filename format bottom sheet"
},
"downloadFilenameInsertTag": "Tap to insert tag:",
"@downloadFilenameInsertTag": {
"description": "Label above filename tag chips"
},
"downloadSeparateSinglesEnabled": "Albums/ and Singles/ folders",
"@downloadSeparateSinglesEnabled": {
"description": "Subtitle when separate singles folder is enabled"
},
"downloadSeparateSinglesDisabled": "All files in same structure",
"@downloadSeparateSinglesDisabled": {
"description": "Subtitle when separate singles folder is disabled"
},
"downloadArtistNameFilters": "Artist Name Filters",
"@downloadArtistNameFilters": {
"description": "Setting title for artist folder filter options"
},
"downloadCreatePlaylistSourceFolder": "Create playlist source folder",
"@downloadCreatePlaylistSourceFolder": {
"description": "Setting title for adding a playlist folder prefix before the normal organization structure"
},
"downloadCreatePlaylistSourceFolderEnabled": "Playlist downloads use Playlist/ plus your normal folder structure.",
"@downloadCreatePlaylistSourceFolderEnabled": {
"description": "Subtitle when playlist source folder prefix is enabled"
},
"downloadCreatePlaylistSourceFolderDisabled": "Playlist downloads use the normal folder structure only.",
"@downloadCreatePlaylistSourceFolderDisabled": {
"description": "Subtitle when playlist source folder prefix is disabled"
},
"downloadCreatePlaylistSourceFolderRedundant": "By Playlist already places downloads inside a playlist folder.",
"@downloadCreatePlaylistSourceFolderRedundant": {
"description": "Subtitle when playlist folder prefix setting is redundant because folder organization is already by playlist"
},
"downloadSongLinkRegion": "SongLink Region",
"@downloadSongLinkRegion": {
"description": "Setting title for SongLink country region"
},
"downloadNetworkCompatibilityMode": "Network compatibility mode",
"@downloadNetworkCompatibilityMode": {
"description": "Setting title for network compatibility toggle"
},
"downloadNetworkCompatibilityModeEnabled": "Enabled: try HTTP + accept invalid TLS certificates (unsafe)",
"@downloadNetworkCompatibilityModeEnabled": {
"description": "Subtitle when network compatibility mode is enabled"
},
"downloadNetworkCompatibilityModeDisabled": "Off: strict HTTPS certificate validation (recommended)",
"@downloadNetworkCompatibilityModeDisabled": {
"description": "Subtitle when network compatibility mode is disabled"
},
"downloadSelectServiceToEnable": "Select a built-in service to enable",
"@downloadSelectServiceToEnable": {
"description": "Hint shown instead of Ask-quality subtitle when no built-in service selected"
},
"downloadSelectTidalQobuz": "Select Tidal or Qobuz above to configure quality",
"@downloadSelectTidalQobuz": {
"description": "Info hint when non-Tidal/Qobuz service is selected"
},
"downloadEmbedLyricsDisabled": "Disabled while Embed Metadata is turned off",
"@downloadEmbedLyricsDisabled": {
"description": "Subtitle for Embed Lyrics when Embed Metadata is disabled"
},
"downloadNeteaseIncludeTranslation": "Netease: Include Translation",
"@downloadNeteaseIncludeTranslation": {
"description": "Toggle title for including Netease translated lyrics"
},
"downloadNeteaseIncludeTranslationEnabled": "Append translated lyrics when available",
"@downloadNeteaseIncludeTranslationEnabled": {
"description": "Subtitle when Netease translation is enabled"
},
"downloadNeteaseIncludeTranslationDisabled": "Use original lyrics only",
"@downloadNeteaseIncludeTranslationDisabled": {
"description": "Subtitle when Netease translation is disabled"
},
"downloadNeteaseIncludeRomanization": "Netease: Include Romanization",
"@downloadNeteaseIncludeRomanization": {
"description": "Toggle title for including Netease romanized lyrics"
},
"downloadNeteaseIncludeRomanizationEnabled": "Append romanized lyrics when available",
"@downloadNeteaseIncludeRomanizationEnabled": {
"description": "Subtitle when Netease romanization is enabled"
},
"downloadNeteaseIncludeRomanizationDisabled": "Disabled",
"@downloadNeteaseIncludeRomanizationDisabled": {
"description": "Subtitle when Netease romanization is disabled"
},
"downloadAppleQqMultiPerson": "Apple/QQ Multi-Person Word-by-Word",
"@downloadAppleQqMultiPerson": {
"description": "Toggle title for Apple/QQ multi-person word-by-word lyrics"
},
"downloadAppleQqMultiPersonEnabled": "Enable v1/v2 speaker and [bg:] tags",
"@downloadAppleQqMultiPersonEnabled": {
"description": "Subtitle when multi-person word-by-word is enabled"
},
"downloadAppleQqMultiPersonDisabled": "Simplified word-by-word formatting",
"@downloadAppleQqMultiPersonDisabled": {
"description": "Subtitle when multi-person word-by-word is disabled"
},
"downloadMusixmatchLanguage": "Musixmatch Language",
"@downloadMusixmatchLanguage": {
"description": "Setting title for Musixmatch language preference"
},
"downloadMusixmatchLanguageAuto": "Auto (original)",
"@downloadMusixmatchLanguageAuto": {
"description": "Option label when Musixmatch uses original language"
},
"downloadFilterContributing": "Filter contributing artists in Album Artist",
"@downloadFilterContributing": {
"description": "Toggle title for filtering contributing artists in Album Artist metadata"
},
"downloadFilterContributingEnabled": "Album Artist metadata uses primary artist only",
"@downloadFilterContributingEnabled": {
"description": "Subtitle when contributing artist filter is enabled"
},
"downloadFilterContributingDisabled": "Keep full Album Artist metadata value",
"@downloadFilterContributingDisabled": {
"description": "Subtitle when contributing artist filter is disabled"
},
"downloadProvidersNoneEnabled": "None enabled",
"@downloadProvidersNoneEnabled": {
"description": "Subtitle for lyrics providers setting when no providers are enabled"
},
"downloadMusixmatchLanguageCode": "Language code",
"@downloadMusixmatchLanguageCode": {
"description": "Label for the Musixmatch language code text field"
},
"downloadMusixmatchLanguageHint": "auto / en / es / ja",
"@downloadMusixmatchLanguageHint": {
"description": "Hint text for the Musixmatch language code field"
},
"downloadMusixmatchLanguageDesc": "Set preferred language code (example: en, es, ja). Leave empty for auto.",
"@downloadMusixmatchLanguageDesc": {
"description": "Description in the Musixmatch language picker"
},
"downloadMusixmatchAuto": "Auto",
"@downloadMusixmatchAuto": {
"description": "Button to reset Musixmatch language to automatic"
},
"downloadNetworkAnySubtitle": "WiFi + Mobile Data",
"@downloadNetworkAnySubtitle": {
"description": "Subtitle for 'Any' network mode option"
},
"downloadNetworkWifiOnlySubtitle": "Pause downloads on mobile data",
"@downloadNetworkWifiOnlySubtitle": {
"description": "Subtitle for 'WiFi only' network mode option"
},
"downloadSongLinkRegionDesc": "Used as userCountry for SongLink API lookup.",
"@downloadSongLinkRegionDesc": {
"description": "Description in the SongLink region picker"
},
"downloadFolderOrganization": "Folder Organization",
"@downloadFolderOrganization": {
"description": "Title of the folder organization picker bottom sheet"
},
"snackbarUnsupportedAudioFormat": "Unsupported audio format",
"@snackbarUnsupportedAudioFormat": {
"description": "Snackbar when the audio format is not supported for the requested operation"
},
"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"
}
}
+78 -5
View File
@@ -897,15 +897,15 @@
"@errorNoTracksFound": {
"description": "Error - search returned no results"
},
"errorUrlNotRecognized": "Tautan tidak dikenali",
"errorUrlNotRecognized": "Link tidak dikenali",
"@errorUrlNotRecognized": {
"description": "Error title - URL not handled by any extension or service"
},
"errorUrlNotRecognizedMessage": "Tautan ini tidak didukung. Pastikan URL sudah benar dan ekstensi yang kompatibel telah terpasang.",
"errorUrlNotRecognizedMessage": "Link ini tidak didukung. Pastikan URL benar dan ekstensi yang kompatibel sudah terpasang.",
"@errorUrlNotRecognizedMessage": {
"description": "Error message - URL not recognized explanation"
},
"errorUrlFetchFailed": "Konten dari tautan ini gagal dimuat. Silakan coba lagi.",
"errorUrlFetchFailed": "Gagal memuat konten dari link ini. Silakan coba lagi.",
"@errorUrlFetchFailed": {
"description": "Error message - generic URL fetch failure"
},
@@ -1805,6 +1805,22 @@
"@downloadUseAlbumArtistForFolders": {
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
},
"downloadCreatePlaylistSourceFolder": "Buat folder sumber playlist",
"@downloadCreatePlaylistSourceFolder": {
"description": "Setting title for adding a playlist folder prefix before the normal organization structure"
},
"downloadCreatePlaylistSourceFolderEnabled": "Unduhan dari playlist memakai Playlist/ lalu struktur folder normal Anda.",
"@downloadCreatePlaylistSourceFolderEnabled": {
"description": "Subtitle when playlist source folder prefix is enabled"
},
"downloadCreatePlaylistSourceFolderDisabled": "Unduhan dari playlist hanya memakai struktur folder normal.",
"@downloadCreatePlaylistSourceFolderDisabled": {
"description": "Subtitle when playlist source folder prefix is disabled"
},
"downloadCreatePlaylistSourceFolderRedundant": "Mode Berdasarkan Playlist sudah menaruh unduhan ke dalam folder playlist.",
"@downloadCreatePlaylistSourceFolderRedundant": {
"description": "Subtitle when playlist folder prefix setting is redundant because folder organization is already by playlist"
},
"downloadUsePrimaryArtistOnly": "Hanya artis utama untuk folder",
"@downloadUsePrimaryArtistOnly": {
"description": "Setting - strip featured artists from folder name"
@@ -2763,6 +2779,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",
@@ -2776,7 +2833,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"
},
@@ -2811,6 +2868,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"
@@ -3206,4 +3279,4 @@
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
"description": "Subtitle when Track Artist is used for folder naming"
}
}
}
+11 -11
View File
@@ -728,7 +728,7 @@
"@dialogDeleteSelectedTitle": {
"description": "Dialog title - delete selected items"
},
"dialogDeleteSelectedMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}} из истории?\n\nЭто также удалит файлы из хранилища.",
"dialogDeleteSelectedMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}} из истории?\n\nЭто также удалит файлы из хранилища.",
"@dialogDeleteSelectedMessage": {
"description": "Dialog message - delete selected tracks",
"placeholders": {
@@ -807,7 +807,7 @@
"@snackbarCredentialsCleared": {
"description": "Snackbar - Spotify credentials removed"
},
"snackbarDeletedTracks": "Удалено {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}}",
"snackbarDeletedTracks": "Удалено {count} {count, plural, one {трек} few {трека} many {треков} other{треков}}",
"@snackbarDeletedTracks": {
"description": "Snackbar - tracks deleted",
"placeholders": {
@@ -1370,7 +1370,7 @@
}
}
},
"tracksCount": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} =1 {1 трек} other {{count} треков}}",
"tracksCount": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} other {{count} треков}}",
"@tracksCount": {
"description": "Track count display",
"placeholders": {
@@ -1909,7 +1909,7 @@
"@downloadedAlbumDeleteSelected": {
"description": "Button - delete selected tracks"
},
"downloadedAlbumDeleteMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}} из этого альбома?\n\nЭто также удалит файлы из хранилища.",
"downloadedAlbumDeleteMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}} из этого альбома?\n\nЭто также удалит файлы из хранилища.",
"@downloadedAlbumDeleteMessage": {
"description": "Delete confirmation with count",
"placeholders": {
@@ -1935,7 +1935,7 @@
"@downloadedAlbumTapToSelect": {
"description": "Selection hint"
},
"downloadedAlbumDeleteCount": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}}",
"downloadedAlbumDeleteCount": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}}",
"@downloadedAlbumDeleteCount": {
"description": "Delete button text with count",
"placeholders": {
@@ -2234,7 +2234,7 @@
"@libraryAboutDescription": {
"description": "Description of local library feature"
},
"libraryTracksUnit": "{count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}}",
"libraryTracksUnit": "{count, plural, one {трек} few {трека} many {треков} other{треков}}",
"@libraryTracksUnit": {
"description": "Unit label for tracks count (without the number itself)",
"placeholders": {
@@ -2373,7 +2373,7 @@
"@timeJustNow": {
"description": "Relative time - less than a minute ago"
},
"timeMinutesAgo": "{count, plural, one {{count} минуту} few {{count} минуты} many {{count} минут} =1 {1 минуту} other {{count} минут}} назад",
"timeMinutesAgo": "{count, plural, one {{count} минуту} few {{count} минуты} many {{count} минут} other {{count} минут}} назад",
"@timeMinutesAgo": {
"description": "Relative time - minutes ago",
"placeholders": {
@@ -2382,7 +2382,7 @@
}
}
},
"timeHoursAgo": "{count, plural, one {{count} час} few {{count} часа} many {{count} часов} =1 {1 час} other {{count} часов}} назад",
"timeHoursAgo": "{count, plural, one {{count} час} few {{count} часа} many {{count} часов} other {{count} часов}} назад",
"@timeHoursAgo": {
"description": "Relative time - hours ago",
"placeholders": {
@@ -2952,7 +2952,7 @@
"@collectionNoPlaylistsSubtitle": {
"description": "Empty state subtitle when user has no playlists"
},
"collectionPlaylistTracks": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} =1 {1 трек} other {{count} треков}}",
"collectionPlaylistTracks": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} other {{count} треков}}",
"@collectionPlaylistTracks": {
"description": "Track count label for custom playlists",
"placeholders": {
@@ -3117,7 +3117,7 @@
"@collectionPlaylistRemoveCover": {
"description": "Bottom sheet action to remove custom cover image from a playlist"
},
"selectionShareCount": "Отправить {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}}",
"selectionShareCount": "Отправить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}}",
"@selectionShareCount": {
"description": "Share button text with count in selection mode",
"placeholders": {
@@ -3130,7 +3130,7 @@
"@selectionShareNoFiles": {
"description": "Snackbar when no selected files exist on disk"
},
"selectionConvertCount": "Конвертировать {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}}",
"selectionConvertCount": "Конвертировать {count} {count, plural, one {трек} few {трека} many {треков} other{треков}}",
"@selectionConvertCount": {
"description": "Convert button text with count in selection mode",
"placeholders": {
+1 -1
View File
@@ -1,5 +1,5 @@
{
"@@locale": "zh-CN",
"@@locale": "zh_CN",
"@@last_modified": "2026-01-16",
"appName": "SpotiFLAC",
"@appName": {
+1 -1
View File
@@ -1,5 +1,5 @@
{
"@@locale": "zh-TW",
"@@locale": "zh_TW",
"@@last_modified": "2026-01-16",
"appName": "SpotiFLAC",
"@appName": {
+137 -6
View File
@@ -1,16 +1,20 @@
import 'dart:async';
import 'dart:io';
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';
import 'package:spotiflac_android/providers/library_collections_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
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();
@@ -88,15 +92,142 @@ 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();
_initializeAppServices();
_initializeExtensions();
ref.read(downloadHistoryProvider);
ref.read(localLibraryProvider);
ref.read(libraryCollectionsProvider);
WidgetsBinding.instance.addObserver(this);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_initializeAppServices();
_initializeExtensions();
_initializeDeferredProviders();
});
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_localLibraryEnabledSub?.close();
_downloadHistoryWarmupTimer?.cancel();
_libraryCollectionsWarmupTimer?.cancel();
_localLibraryWarmupTimer?.cancel();
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
_maybeAutoScanLocalLibrary();
}
}
void _initializeDeferredProviders() {
_downloadHistoryWarmupTimer = _scheduleProviderWarmup(
const Duration(milliseconds: 400),
() => ref.read(downloadHistoryProvider),
);
_libraryCollectionsWarmupTimer = _scheduleProviderWarmup(
const Duration(milliseconds: 900),
() => ref.read(libraryCollectionsProvider),
);
_maybeScheduleLocalLibraryWarmup(
ref.read(
settingsProvider.select((settings) => settings.localLibraryEnabled),
),
);
_localLibraryEnabledSub = ref.listenManual<bool>(
settingsProvider.select((settings) => settings.localLibraryEnabled),
(previous, next) {
if (next == true) {
_maybeScheduleLocalLibraryWarmup(true);
}
},
);
}
Timer _scheduleProviderWarmup(Duration delay, VoidCallback action) {
return Timer(delay, () {
if (!mounted) return;
action();
});
}
void _maybeScheduleLocalLibraryWarmup(bool enabled) {
if (!enabled || _localLibraryWarmupScheduled) return;
_localLibraryWarmupScheduled = true;
_localLibraryWarmupTimer = _scheduleProviderWarmup(
const Duration(milliseconds: 1600),
() {
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,
);
}
Future<void> _initializeAppServices() async {
+4
View File
@@ -34,6 +34,7 @@ class DownloadItem {
final DownloadErrorType? errorType;
final DateTime createdAt;
final String? qualityOverride; // Override quality for this specific download
final String? playlistName; // Playlist context for folder organization
const DownloadItem({
required this.id,
@@ -48,6 +49,7 @@ class DownloadItem {
this.errorType,
required this.createdAt,
this.qualityOverride,
this.playlistName,
});
DownloadItem copyWith({
@@ -63,6 +65,7 @@ class DownloadItem {
DownloadErrorType? errorType,
DateTime? createdAt,
String? qualityOverride,
String? playlistName,
}) {
return DownloadItem(
id: id ?? this.id,
@@ -77,6 +80,7 @@ class DownloadItem {
errorType: errorType ?? this.errorType,
createdAt: createdAt ?? this.createdAt,
qualityOverride: qualityOverride ?? this.qualityOverride,
playlistName: playlistName ?? this.playlistName,
);
}
+2
View File
@@ -21,6 +21,7 @@ DownloadItem _$DownloadItemFromJson(Map<String, dynamic> json) => DownloadItem(
errorType: $enumDecodeNullable(_$DownloadErrorTypeEnumMap, json['errorType']),
createdAt: DateTime.parse(json['createdAt'] as String),
qualityOverride: json['qualityOverride'] as String?,
playlistName: json['playlistName'] as String?,
);
Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
@@ -37,6 +38,7 @@ Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
'errorType': _$DownloadErrorTypeEnumMap[instance.errorType],
'createdAt': instance.createdAt.toIso8601String(),
'qualityOverride': instance.qualityOverride,
'playlistName': instance.playlistName,
};
const _$DownloadStatusEnumMap = {
+19 -3
View File
@@ -20,6 +20,7 @@ class AppSettings {
final String updateChannel;
final bool hasSearchedBefore;
final String folderOrganization;
final bool createPlaylistFolder;
final bool useAlbumArtistForFolders;
final bool usePrimaryArtistOnly; // Strip featured artists from folder name
final bool filterContributingArtistsInAlbumArtist;
@@ -33,6 +34,7 @@ class AppSettings {
final bool enableLogging;
final bool useExtensionProviders;
final String? searchProvider;
final String? homeFeedProvider;
final bool separateSingles;
final String albumFolderStructure;
final bool showExtensionStore;
@@ -41,7 +43,7 @@ class AppSettings {
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 +63,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
@@ -96,6 +100,7 @@ class AppSettings {
this.updateChannel = 'stable',
this.hasSearchedBefore = false,
this.folderOrganization = 'none',
this.createPlaylistFolder = false,
this.useAlbumArtistForFolders = true,
this.usePrimaryArtistOnly = false,
this.filterContributingArtistsInAlbumArtist = false,
@@ -104,11 +109,12 @@ class AppSettings {
this.askQualityBeforeDownload = true,
this.spotifyClientId = '',
this.spotifyClientSecret = '',
this.useCustomSpotifyCredentials = true,
this.useCustomSpotifyCredentials = false,
this.metadataSource = 'deezer',
this.enableLogging = false,
this.useExtensionProviders = true,
this.searchProvider,
this.homeFeedProvider,
this.separateSingles = false,
this.albumFolderStructure = 'artist_album',
this.showExtensionStore = true,
@@ -126,6 +132,7 @@ class AppSettings {
this.localLibraryPath = '',
this.localLibraryBookmark = '',
this.localLibraryShowDuplicates = true,
this.localLibraryAutoScan = 'off',
this.hasCompletedTutorial = false,
this.lyricsProviders = const [
'lrclib',
@@ -149,7 +156,7 @@ class AppSettings {
String? downloadDirectory,
String? storageMode,
String? downloadTreeUri,
bool? autoFallback,
bool? autoFallback,
bool? embedMetadata,
bool? embedLyrics,
bool? maxQualityCover,
@@ -159,6 +166,7 @@ class AppSettings {
String? updateChannel,
bool? hasSearchedBefore,
String? folderOrganization,
bool? createPlaylistFolder,
bool? useAlbumArtistForFolders,
bool? usePrimaryArtistOnly,
bool? filterContributingArtistsInAlbumArtist,
@@ -173,6 +181,8 @@ class AppSettings {
bool? useExtensionProviders,
String? searchProvider,
bool clearSearchProvider = false,
String? homeFeedProvider,
bool clearHomeFeedProvider = false,
bool? separateSingles,
String? albumFolderStructure,
bool? showExtensionStore,
@@ -190,6 +200,7 @@ class AppSettings {
String? localLibraryPath,
String? localLibraryBookmark,
bool? localLibraryShowDuplicates,
String? localLibraryAutoScan,
bool? hasCompletedTutorial,
List<String>? lyricsProviders,
bool? lyricsIncludeTranslationNetease,
@@ -215,6 +226,7 @@ class AppSettings {
updateChannel: updateChannel ?? this.updateChannel,
hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore,
folderOrganization: folderOrganization ?? this.folderOrganization,
createPlaylistFolder: createPlaylistFolder ?? this.createPlaylistFolder,
useAlbumArtistForFolders:
useAlbumArtistForFolders ?? this.useAlbumArtistForFolders,
usePrimaryArtistOnly: usePrimaryArtistOnly ?? this.usePrimaryArtistOnly,
@@ -236,6 +248,9 @@ class AppSettings {
searchProvider: clearSearchProvider
? null
: (searchProvider ?? this.searchProvider),
homeFeedProvider: clearHomeFeedProvider
? null
: (homeFeedProvider ?? this.homeFeedProvider),
separateSingles: separateSingles ?? this.separateSingles,
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
@@ -256,6 +271,7 @@ class AppSettings {
localLibraryBookmark: localLibraryBookmark ?? this.localLibraryBookmark,
localLibraryShowDuplicates:
localLibraryShowDuplicates ?? this.localLibraryShowDuplicates,
localLibraryAutoScan: localLibraryAutoScan ?? this.localLibraryAutoScan,
hasCompletedTutorial: hasCompletedTutorial ?? this.hasCompletedTutorial,
lyricsProviders: lyricsProviders ?? this.lyricsProviders,
lyricsIncludeTranslationNetease:
+7 -1
View File
@@ -23,6 +23,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
updateChannel: json['updateChannel'] as String? ?? 'stable',
hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false,
folderOrganization: json['folderOrganization'] as String? ?? 'none',
createPlaylistFolder: json['createPlaylistFolder'] as bool? ?? false,
useAlbumArtistForFolders: json['useAlbumArtistForFolders'] as bool? ?? true,
usePrimaryArtistOnly: json['usePrimaryArtistOnly'] as bool? ?? false,
filterContributingArtistsInAlbumArtist:
@@ -33,11 +34,12 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
spotifyClientId: json['spotifyClientId'] as String? ?? '',
spotifyClientSecret: json['spotifyClientSecret'] as String? ?? '',
useCustomSpotifyCredentials:
json['useCustomSpotifyCredentials'] as bool? ?? true,
json['useCustomSpotifyCredentials'] as bool? ?? false,
metadataSource: json['metadataSource'] as String? ?? 'deezer',
enableLogging: json['enableLogging'] as bool? ?? false,
useExtensionProviders: json['useExtensionProviders'] as bool? ?? true,
searchProvider: json['searchProvider'] as String?,
homeFeedProvider: json['homeFeedProvider'] as String?,
separateSingles: json['separateSingles'] as bool? ?? false,
albumFolderStructure:
json['albumFolderStructure'] as String? ?? 'artist_album',
@@ -58,6 +60,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>?)
@@ -100,6 +103,7 @@ Map<String, dynamic> _$AppSettingsToJson(
'updateChannel': instance.updateChannel,
'hasSearchedBefore': instance.hasSearchedBefore,
'folderOrganization': instance.folderOrganization,
'createPlaylistFolder': instance.createPlaylistFolder,
'useAlbumArtistForFolders': instance.useAlbumArtistForFolders,
'usePrimaryArtistOnly': instance.usePrimaryArtistOnly,
'filterContributingArtistsInAlbumArtist':
@@ -114,6 +118,7 @@ Map<String, dynamic> _$AppSettingsToJson(
'enableLogging': instance.enableLogging,
'useExtensionProviders': instance.useExtensionProviders,
'searchProvider': instance.searchProvider,
'homeFeedProvider': instance.homeFeedProvider,
'separateSingles': instance.separateSingles,
'albumFolderStructure': instance.albumFolderStructure,
'showExtensionStore': instance.showExtensionStore,
@@ -131,6 +136,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,
+13 -1
View File
@@ -21,6 +21,7 @@ class Track {
final ServiceAvailability? availability;
final String? source;
final String? albumType;
final int? totalTracks;
final String? itemType;
const Track({
@@ -41,10 +42,21 @@ class Track {
this.availability,
this.source,
this.albumType,
this.totalTracks,
this.itemType,
});
bool get isSingle => albumType == 'single' || albumType == 'ep';
bool get isSingle {
switch (albumType?.toLowerCase()) {
case 'single':
return true;
case 'ep':
final count = totalTracks;
return count == null || count <= 1;
default:
return false;
}
}
bool get isAlbumItem => itemType == 'album';
+2
View File
@@ -28,6 +28,7 @@ Track _$TrackFromJson(Map<String, dynamic> json) => Track(
),
source: json['source'] as String?,
albumType: json['albumType'] as String?,
totalTracks: (json['totalTracks'] as num?)?.toInt(),
itemType: json['itemType'] as String?,
);
@@ -49,6 +50,7 @@ Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
'availability': instance.availability,
'source': instance.source,
'albumType': instance.albumType,
'totalTracks': instance.totalTracks,
'itemType': instance.itemType,
};
File diff suppressed because it is too large Load Diff
+29 -23
View File
@@ -4,6 +4,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
final _log = AppLogger('ExploreProvider');
@@ -202,9 +203,7 @@ class ExploreNotifier extends Notifier<ExploreState> {
Future<void> _saveToCache(List<ExploreSection> sections) async {
try {
final prefs = await SharedPreferences.getInstance();
final data = {
'sections': sections.map((s) => s.toJson()).toList(),
};
final data = {'sections': sections.map((s) => s.toJson()).toList()};
await prefs.setString(_cacheKey, jsonEncode(data));
await prefs.setInt(_cacheTsKey, DateTime.now().millisecondsSinceEpoch);
_log.d('Saved ${sections.length} explore sections to cache');
@@ -216,16 +215,16 @@ class ExploreNotifier extends Notifier<ExploreState> {
/// Fetch home feed from spotify-web extension
Future<void> fetchHomeFeed({bool forceRefresh = false}) async {
_log.i('fetchHomeFeed called, forceRefresh=$forceRefresh');
// If we have cached content and it's fresh enough, skip network fetch
if (!forceRefresh &&
state.hasContent &&
if (!forceRefresh &&
state.hasContent &&
state.lastFetched != null &&
DateTime.now().difference(state.lastFetched!).inMinutes < 5) {
_log.d('Using cached home feed (fresh enough)');
return;
}
if (state.isLoading) {
_log.d('Home feed fetch already in progress');
return;
@@ -237,21 +236,33 @@ class ExploreNotifier extends Notifier<ExploreState> {
try {
final extState = ref.read(extensionProvider);
_log.d('Extensions count: ${extState.extensions.length}');
final settings = ref.read(settingsProvider);
final preferredId = settings.homeFeedProvider;
_log.d(
'Extensions count: ${extState.extensions.length}, preferred home feed: $preferredId',
);
Extension? targetExt;
for (final extension in extState.extensions) {
if (!extension.enabled || !extension.hasHomeFeed) {
continue;
}
// If user has a preference, use that
if (preferredId != null &&
preferredId.isNotEmpty &&
extension.id == preferredId) {
targetExt = extension;
break;
}
// Otherwise take the first available (fallback to spotify-web if found)
if (targetExt == null || extension.id == 'spotify-web') {
targetExt = extension;
if (extension.id == 'spotify-web') {
if (preferredId == null && extension.id == 'spotify-web') {
break;
}
}
}
if (targetExt == null) {
_log.w('No extension with homeFeed capability found');
state = state.copyWith(
@@ -260,7 +271,7 @@ class ExploreNotifier extends Notifier<ExploreState> {
);
return;
}
_log.i('Fetching home feed from ${targetExt.id}...');
final result = await PlatformBridge.getExtensionHomeFeed(targetExt.id);
@@ -276,10 +287,7 @@ class ExploreNotifier extends Notifier<ExploreState> {
_log.d('getExtensionHomeFeed success=$success');
if (!success) {
final error = result['error'] as String? ?? 'Unknown error';
state = state.copyWith(
isLoading: false,
error: error,
);
state = state.copyWith(isLoading: false, error: error);
return;
}
@@ -291,10 +299,12 @@ class ExploreNotifier extends Notifier<ExploreState> {
.toList();
_log.i('Fetched ${sections.length} sections');
if (sections.isNotEmpty && sections.first.items.isNotEmpty) {
final firstItem = sections.first.items.first;
_log.d('First item: name=${firstItem.name}, artists=${firstItem.artists}, type=${firstItem.type}');
_log.d(
'First item: name=${firstItem.name}, artists=${firstItem.artists}, type=${firstItem.type}',
);
}
final localGreeting = _getLocalGreeting();
@@ -311,10 +321,7 @@ class ExploreNotifier extends Notifier<ExploreState> {
_saveToCache(sections);
} catch (e, stack) {
_log.e('Error fetching home feed: $e', e, stack);
state = state.copyWith(
isLoading: false,
error: e.toString(),
);
state = state.copyWith(isLoading: false, error: e.toString());
}
}
@@ -325,7 +332,6 @@ class ExploreNotifier extends Notifier<ExploreState> {
Future<void> refresh() => fetchHomeFeed(forceRefresh: true);
}
final exploreProvider = NotifierProvider<ExploreNotifier, ExploreState>(() {
return ExploreNotifier();
});
+54 -7
View File
@@ -504,6 +504,11 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
Future<void> _cleanupExtensions({required String reason}) async {
if (!PlatformBridge.supportsExtensionSystem) {
_cleanupInFlight = false;
return;
}
try {
await PlatformBridge.cleanupExtensions();
_log.d('Extensions cleaned up ($reason)');
@@ -519,6 +524,17 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
state = state.copyWith(isLoading: true, error: null);
if (!PlatformBridge.supportsExtensionSystem) {
state = state.copyWith(
isInitialized: true,
isLoading: false,
extensions: const [],
error: null,
);
_log.i('Extension system disabled on this platform');
return;
}
try {
await PlatformBridge.initExtensionSystem(extensionsDir, dataDir);
await loadExtensions(extensionsDir);
@@ -823,11 +839,19 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
List<String> priority;
if (savedJson != null) {
final saved = jsonDecode(savedJson) as List<dynamic>;
priority = saved.map((e) => e as String).toList();
priority = _sanitizeMetadataProviderPriority(
saved.map((e) => e as String).toList(),
);
_log.d('Loaded metadata provider priority from prefs: $priority');
await prefs.setString(
_metadataProviderPriorityKey,
jsonEncode(priority),
);
await PlatformBridge.setMetadataProviderPriority(priority);
} else {
priority = await PlatformBridge.getMetadataProviderPriority();
priority = _sanitizeMetadataProviderPriority(
await PlatformBridge.getMetadataProviderPriority(),
);
_log.d('Using default metadata provider priority: $priority');
}
@@ -840,11 +864,15 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
Future<void> setMetadataProviderPriority(List<String> priority) async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_metadataProviderPriorityKey, jsonEncode(priority));
final sanitized = _sanitizeMetadataProviderPriority(priority);
await prefs.setString(
_metadataProviderPriorityKey,
jsonEncode(sanitized),
);
await PlatformBridge.setMetadataProviderPriority(priority);
state = state.copyWith(metadataProviderPriority: priority);
_log.d('Saved metadata provider priority: $priority');
await PlatformBridge.setMetadataProviderPriority(sanitized);
state = state.copyWith(metadataProviderPriority: sanitized);
_log.d('Saved metadata provider priority: $sanitized');
} catch (e) {
_log.e('Failed to set metadata provider priority: $e');
state = state.copyWith(error: e.toString());
@@ -880,7 +908,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
List<String> getAllMetadataProviders() {
final providers = ['deezer', 'spotify'];
final providers = ['deezer', 'qobuz', 'tidal'];
for (final ext in state.extensions) {
if (ext.enabled && ext.hasMetadataProvider) {
providers.add(ext.id);
@@ -889,6 +917,25 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
return providers;
}
List<String> _sanitizeMetadataProviderPriority(List<String> input) {
final allowed = getAllMetadataProviders().toSet();
final result = <String>[];
for (final provider in input) {
if (allowed.contains(provider) && !result.contains(provider)) {
result.add(provider);
}
}
for (final provider in const ['deezer', 'qobuz', 'tidal']) {
if (!result.contains(provider)) {
result.add(provider);
}
}
return result;
}
List<Extension> get searchProviders {
return state.extensions
.where((ext) => ext.enabled && ext.hasCustomSearch)
+70 -99
View File
@@ -9,10 +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();
@@ -119,7 +120,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
final LibraryDatabase _db = LibraryDatabase.instance;
final HistoryDatabase _historyDb = HistoryDatabase.instance;
final NotificationService _notificationService = NotificationService();
static const _progressPollingInterval = Duration(milliseconds: 800);
static const _progressPollingInterval = Duration(milliseconds: 1200);
Timer? _progressTimer;
Timer? _progressStreamBootstrapTimer;
StreamSubscription<Map<String, dynamic>>? _progressStreamSub;
@@ -164,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) {
@@ -193,74 +191,11 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
await _loadFromDatabase();
}
Set<String> _buildPathMatchKeys(String? filePath) {
final raw = filePath?.trim() ?? '';
if (raw.isEmpty) return const {};
final cleaned = raw.startsWith('EXISTS:') ? raw.substring(7) : raw;
final keys = <String>{};
void addNormalized(String value) {
final trimmed = value.trim();
if (trimmed.isEmpty) return;
keys.add(trimmed);
keys.add(trimmed.toLowerCase());
if (trimmed.contains('\\')) {
final slash = trimmed.replaceAll('\\', '/');
keys.add(slash);
keys.add(slash.toLowerCase());
}
if (trimmed.contains('%')) {
try {
final decoded = Uri.decodeFull(trimmed);
keys.add(decoded);
keys.add(decoded.toLowerCase());
} catch (_) {}
}
Uri? parsed;
try {
parsed = Uri.parse(trimmed);
} catch (_) {}
if (parsed != null && parsed.hasScheme) {
final noQueryOrFragment = parsed.replace(query: null, fragment: null);
keys.add(noQueryOrFragment.toString());
keys.add(noQueryOrFragment.toString().toLowerCase());
if (parsed.scheme == 'file') {
try {
final fileOnly = parsed.toFilePath();
if (fileOnly.isNotEmpty) {
keys.add(fileOnly);
keys.add(fileOnly.toLowerCase());
if (fileOnly.contains('\\')) {
final slash = fileOnly.replaceAll('\\', '/');
keys.add(slash);
keys.add(slash.toLowerCase());
}
}
} catch (_) {}
}
} else if (trimmed.startsWith('/')) {
try {
final asFileUri = Uri.file(trimmed).toString();
keys.add(asFileUri);
keys.add(asFileUri.toLowerCase());
} catch (_) {}
}
}
addNormalized(cleaned);
return keys;
}
bool _isDownloadedPath(String? filePath, Set<String> downloadedPathKeys) {
if (filePath == null || filePath.isEmpty || downloadedPathKeys.isEmpty) {
return false;
}
final candidateKeys = _buildPathMatchKeys(filePath);
final candidateKeys = buildPathMatchKeys(filePath);
for (final key in candidateKeys) {
if (downloadedPathKeys.contains(key)) {
return true;
@@ -322,8 +257,9 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
String? resolvedPath;
bool didStartSecurityAccess = false;
if (Platform.isIOS && iosBookmark != null && iosBookmark.isNotEmpty) {
resolvedPath =
await PlatformBridge.startAccessingIosBookmark(iosBookmark);
resolvedPath = await PlatformBridge.startAccessingIosBookmark(
iosBookmark,
);
if (resolvedPath != null) {
didStartSecurityAccess = true;
_log.i('Started iOS security-scoped access: $resolvedPath');
@@ -354,7 +290,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
};
final downloadedPathKeys = <String>{};
for (final path in allHistoryPaths) {
downloadedPathKeys.addAll(_buildPathMatchKeys(path));
downloadedPathKeys.addAll(buildPathMatchKeys(path));
}
_log.i(
'Excluding ${allHistoryPaths.length} downloaded files from library scan '
@@ -376,7 +312,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
int skippedDownloads = 0;
for (final json in results) {
final filePath = json['filePath'] as String?;
// Skip files that are already in download history
if (_isDownloadedPath(filePath, downloadedPathKeys)) {
skippedDownloads++;
continue;
@@ -394,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) {
@@ -406,7 +346,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
}
state = state.copyWith(
items: items,
items: persistedItems,
isScanning: false,
scanProgress: 100,
lastScannedAt: now,
@@ -415,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,
);
@@ -440,18 +380,41 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
_log.i('Backfilled ${backfilledModTimes.length} legacy mod times');
}
// Use appropriate incremental scan method based on SAF or not
final Map<String, dynamic> result;
if (isSaf) {
result = await PlatformBridge.scanSafTreeIncremental(
effectiveFolderPath,
existingFiles,
);
} else {
result = await PlatformBridge.scanLibraryFolderIncremental(
effectiveFolderPath,
existingFiles,
);
final useSnapshotBridge =
Platform.isAndroid && existingFiles.isNotEmpty;
final snapshotPath = useSnapshotBridge
? await _db.writeFileModTimesSnapshot()
: null;
Map<String, dynamic> result;
try {
if (isSaf) {
result = useSnapshotBridge && snapshotPath != null
? await PlatformBridge.scanSafTreeIncrementalFromSnapshot(
effectiveFolderPath,
snapshotPath,
)
: await PlatformBridge.scanSafTreeIncremental(
effectiveFolderPath,
existingFiles,
);
} else {
result = useSnapshotBridge && snapshotPath != null
? await PlatformBridge.scanLibraryFolderIncrementalFromSnapshot(
effectiveFolderPath,
snapshotPath,
)
: await PlatformBridge.scanLibraryFolderIncremental(
effectiveFolderPath,
existingFiles,
);
}
} finally {
if (snapshotPath != null) {
try {
await File(snapshotPath).delete();
} catch (_) {}
}
}
if (_scanCancelRequested) {
@@ -460,7 +423,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
return;
}
// Parse incremental scan result
// SAF returns 'files' and 'removedUris', non-SAF returns 'scanned' and 'deletedPaths'
final scannedList =
(result['files'] as List<dynamic>?) ??
@@ -482,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, _) {
@@ -526,7 +494,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
}
}
// Delete removed items
if (deletedPaths.isNotEmpty) {
final deleteCount = await _db.deleteByPaths(deletedPaths);
for (final path in deletedPaths) {
@@ -535,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) {
@@ -834,8 +804,9 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
Future<int> cleanupMissingFiles({String? iosBookmark}) async {
bool didStartSecurityAccess = false;
if (Platform.isIOS && iosBookmark != null && iosBookmark.isNotEmpty) {
final resolved =
await PlatformBridge.startAccessingIosBookmark(iosBookmark);
final resolved = await PlatformBridge.startAccessingIosBookmark(
iosBookmark,
);
if (resolved != null) {
didStartSecurityAccess = true;
}
@@ -858,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');
+12
View File
@@ -24,6 +24,9 @@ class PlaybackController extends Notifier<PlaybackState> {
String coverUrl = '',
Track? track,
}) async {
if (isCueVirtualPath(path)) {
throw Exception(cueVirtualTrackRequiresSplitMessage);
}
_log.d('Opening external player for "$title" by $artist: $path');
await openFile(path);
}
@@ -32,11 +35,16 @@ class PlaybackController extends Notifier<PlaybackState> {
if (tracks.isEmpty) return;
final orderedTracks = _orderedTracksFromStartIndex(tracks, startIndex);
var skippedCueVirtualTrack = false;
for (final track in orderedTracks) {
final resolvedPath = await _resolveTrackPath(track);
if (resolvedPath == null) {
continue;
}
if (isCueVirtualPath(resolvedPath)) {
skippedCueVirtualTrack = true;
continue;
}
_log.d(
'Opening first available external track for list playback: '
@@ -46,6 +54,10 @@ class PlaybackController extends Notifier<PlaybackState> {
return;
}
if (skippedCueVirtualTrack) {
throw Exception(cueVirtualTrackRequiresSplitMessage);
}
throw Exception(
'No local audio file is available to open. Download the track first.',
);
+1 -1
View File
@@ -70,7 +70,7 @@ class RecentAccessItem {
/// State for recent access history
class RecentAccessState {
final List<RecentAccessItem> items;
final Set<String> hiddenDownloadIds; // IDs of downloads hidden from recents
final Set<String> hiddenDownloadIds;
final bool isLoaded;
const RecentAccessState({
+69 -78
View File
@@ -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 = 4;
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,13 +39,12 @@ class SettingsNotifier extends Notifier<AppSettings> {
state = AppSettings.fromJson(jsonDecode(json));
await _runMigrations(prefs);
await _normalizeIosDownloadDirectoryIfNeeded();
await _normalizeYouTubeBitratesIfNeeded();
await _normalizeSongLinkRegionIfNeeded();
}
await _loadSpotifyClientSecret(prefs);
_applySpotifyCredentials();
await _retireBuiltInSpotifyProvider();
LogBuffer.loggingEnabled = state.enableLogging;
@@ -52,6 +53,8 @@ class SettingsNotifier extends Notifier<AppSettings> {
}
void _syncLyricsSettingsToBackend() {
if (!PlatformBridge.supportsCoreBackend) return;
PlatformBridge.setLyricsProviders(state.lyricsProviders).catchError((e) {
_log.w('Failed to sync lyrics providers to backend: $e');
});
@@ -67,6 +70,8 @@ class SettingsNotifier extends Notifier<AppSettings> {
}
void _syncNetworkCompatibilitySettingsToBackend() {
if (!PlatformBridge.supportsCoreBackend) return;
final compatibilityMode = state.networkCompatibilityMode;
PlatformBridge.setNetworkCompatibilityOptions(
allowHttp: compatibilityMode,
@@ -105,6 +110,17 @@ class SettingsNotifier extends Notifier<AppSettings> {
}
state = state.copyWith(lyricsProviders: updatedProviders);
}
if (state.metadataSource != 'deezer' ||
state.spotifyClientId.isNotEmpty ||
state.spotifyClientSecret.isNotEmpty ||
state.useCustomSpotifyCredentials) {
state = state.copyWith(
metadataSource: 'deezer',
spotifyClientId: '',
spotifyClientSecret: '',
useCustomSpotifyCredentials: false,
);
}
state = state.copyWith(lastSeenVersion: AppInfo.version);
await prefs.setInt(_migrationVersionKey, _currentMigrationVersion);
await _saveSettings();
@@ -180,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;
@@ -193,49 +223,28 @@ class SettingsNotifier extends Notifier<AppSettings> {
await _saveSettings();
}
Future<void> _loadSpotifyClientSecret(SharedPreferences prefs) async {
Future<void> _retireBuiltInSpotifyProvider() async {
final storedSecret = await _secureStorage.read(
key: _spotifyClientSecretKey,
);
final prefsSecret = state.spotifyClientSecret;
if ((storedSecret == null || storedSecret.isEmpty) &&
prefsSecret.isNotEmpty) {
await _secureStorage.write(
key: _spotifyClientSecretKey,
value: prefsSecret,
);
}
final effectiveSecret = (storedSecret != null && storedSecret.isNotEmpty)
? storedSecret
: (prefsSecret.isNotEmpty ? prefsSecret : '');
if (effectiveSecret != state.spotifyClientSecret) {
state = state.copyWith(spotifyClientSecret: effectiveSecret);
}
if (prefsSecret.isNotEmpty) {
await _saveSettings();
}
}
Future<void> _storeSpotifyClientSecret(String secret) async {
if (secret.isEmpty) {
if (storedSecret != null && storedSecret.isNotEmpty) {
await _secureStorage.delete(key: _spotifyClientSecretKey);
} else {
await _secureStorage.write(key: _spotifyClientSecretKey, value: secret);
}
}
Future<void> _applySpotifyCredentials() async {
if (state.spotifyClientId.isNotEmpty &&
state.spotifyClientSecret.isNotEmpty) {
await PlatformBridge.setSpotifyCredentials(
state.spotifyClientId,
state.spotifyClientSecret,
);
if (state.metadataSource == 'deezer' &&
state.spotifyClientId.isEmpty &&
state.spotifyClientSecret.isEmpty &&
!state.useCustomSpotifyCredentials) {
return;
}
state = state.copyWith(
metadataSource: 'deezer',
spotifyClientId: '',
spotifyClientSecret: '',
useCustomSpotifyCredentials: false,
);
await _saveSettings();
}
void setDefaultService(String service) {
@@ -366,6 +375,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setCreatePlaylistFolder(bool enabled) {
state = state.copyWith(createPlaylistFolder: enabled);
_saveSettings();
}
void setUseAlbumArtistForFolders(bool enabled) {
state = state.copyWith(useAlbumArtistForFolders: enabled);
_saveSettings();
@@ -396,43 +410,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setSpotifyClientId(String clientId) {
state = state.copyWith(spotifyClientId: clientId);
_saveSettings();
}
Future<void> setSpotifyClientSecret(String clientSecret) async {
state = state.copyWith(spotifyClientSecret: clientSecret);
await _storeSpotifyClientSecret(clientSecret);
_saveSettings();
}
Future<void> setSpotifyCredentials(
String clientId,
String clientSecret,
) async {
state = state.copyWith(
spotifyClientId: clientId,
spotifyClientSecret: clientSecret,
);
await _storeSpotifyClientSecret(clientSecret);
_saveSettings();
_applySpotifyCredentials();
}
Future<void> clearSpotifyCredentials() async {
state = state.copyWith(spotifyClientId: '', spotifyClientSecret: '');
await _storeSpotifyClientSecret('');
_saveSettings();
_applySpotifyCredentials();
}
void setUseCustomSpotifyCredentials(bool enabled) {
state = state.copyWith(useCustomSpotifyCredentials: enabled);
_saveSettings();
_applySpotifyCredentials();
}
void setMetadataSource(String source) {
state = state.copyWith(metadataSource: source);
_saveSettings();
@@ -447,6 +424,15 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setHomeFeedProvider(String? provider) {
if (provider == null || provider.isEmpty) {
state = state.copyWith(clearHomeFeedProvider: true);
} else {
state = state.copyWith(homeFeedProvider: provider);
}
_saveSettings();
}
void setEnableLogging(bool enabled) {
state = state.copyWith(enableLogging: enabled);
_saveSettings();
@@ -550,6 +536,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();
+80 -2
View File
@@ -1,4 +1,5 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/logger.dart';
@@ -6,6 +7,7 @@ import 'package:spotiflac_android/providers/extension_provider.dart';
final _log = AppLogger('StoreProvider');
final RegExp _leadingVersionPrefix = RegExp(r'^v');
const _registryUrlPrefKey = 'store_registry_url';
int compareVersions(String v1, String v2) {
final parts1 = v1.replaceAll(_leadingVersionPrefix, '').split('.');
@@ -125,6 +127,7 @@ class StoreState {
final String? downloadingId;
final String? error;
final bool isInitialized;
final String registryUrl;
const StoreState({
this.extensions = const [],
@@ -135,8 +138,12 @@ class StoreState {
this.downloadingId,
this.error,
this.isInitialized = false,
this.registryUrl = '',
});
/// Whether a registry URL has been configured by the user.
bool get hasRegistryUrl => registryUrl.isNotEmpty;
StoreState copyWith({
List<StoreExtension>? extensions,
String? selectedCategory,
@@ -149,6 +156,7 @@ class StoreState {
String? error,
bool clearError = false,
bool? isInitialized,
String? registryUrl,
}) {
return StoreState(
extensions: extensions ?? this.extensions,
@@ -159,6 +167,7 @@ class StoreState {
downloadingId: clearDownloadingId ? null : (downloadingId ?? this.downloadingId),
error: clearError ? null : (error ?? this.error),
isInitialized: isInitialized ?? this.isInitialized,
registryUrl: registryUrl ?? this.registryUrl,
);
}
@@ -201,15 +210,84 @@ class StoreNotifier extends Notifier<StoreState> {
try {
await PlatformBridge.initExtensionStore(cacheDir);
await refresh();
// Load saved registry URL from SharedPreferences
final prefs = await SharedPreferences.getInstance();
final savedUrl = prefs.getString(_registryUrlPrefKey) ?? '';
if (savedUrl.isNotEmpty) {
await PlatformBridge.setStoreRegistryUrl(savedUrl);
state = state.copyWith(registryUrl: savedUrl);
await refresh();
}
state = state.copyWith(isInitialized: true, isLoading: false);
_log.i('Extension store initialized');
_log.i('Extension store initialized (registryUrl: ${savedUrl.isEmpty ? "not set" : savedUrl})');
} catch (e) {
_log.e('Failed to initialize store: $e');
state = state.copyWith(isLoading: false, error: e.toString());
}
}
/// Sets the registry URL, saves it, and refreshes the store.
/// The Go backend handles URL normalisation (GitHub repo raw URL, branch detection).
Future<void> setRegistryUrl(String url) async {
final trimmed = url.trim();
if (trimmed.isEmpty) {
state = state.copyWith(error: 'Please enter a valid URL');
return;
}
state = state.copyWith(isLoading: true, clearError: true);
try {
// Go backend resolves GitHub URLs (detects default branch) and validates HTTPS.
await PlatformBridge.setStoreRegistryUrl(trimmed);
// Read back the resolved URL (may differ from input after normalisation).
final resolvedUrl = await PlatformBridge.getStoreRegistryUrl();
// Persist to SharedPreferences
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_registryUrlPrefKey, resolvedUrl);
state = state.copyWith(
registryUrl: resolvedUrl,
extensions: const [], // Clear old extensions
);
_log.i('Registry URL set to: $resolvedUrl');
await refresh(forceRefresh: true);
} catch (e) {
_log.e('Failed to set registry URL: $e');
state = state.copyWith(isLoading: false, error: e.toString());
}
}
/// Removes the saved registry URL and fully detaches the repo from backend.
Future<void> removeRegistryUrl() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_registryUrlPrefKey);
// Reset the URL in Go backend memory AND clear its cache
await PlatformBridge.clearStoreRegistryUrl();
state = state.copyWith(
registryUrl: '',
extensions: const [],
clearCategory: true,
searchQuery: '',
clearError: true,
);
_log.i('Registry URL removed');
} catch (e) {
_log.e('Failed to remove registry URL: $e');
state = state.copyWith(error: e.toString());
}
}
Future<void> refresh({bool forceRefresh = false}) async {
state = state.copyWith(isLoading: true, clearError: true);
+259 -145
View File
@@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
@@ -18,7 +19,7 @@ class TrackState {
final String? artistName;
final String? coverUrl;
final String? headerImageUrl; // Artist header image for background
final int? monthlyListeners; // Artist monthly listeners
final int? monthlyListeners;
final List<ArtistAlbum>? artistAlbums; // For artist page
final List<Track>? artistTopTracks; // Artist's popular tracks
final List<SearchArtist>? searchArtists; // For search results
@@ -30,6 +31,8 @@ class TrackState {
searchExtensionId; // Extension ID used for current search results
final String?
selectedSearchFilter; // Currently selected search filter (e.g., "track", "album", "artist", "playlist")
final String?
searchSource; // Built-in search provider used for current results (e.g., "deezer", "tidal", "qobuz")
const TrackState({
this.tracks = const [],
@@ -52,6 +55,7 @@ class TrackState {
this.isShowingRecentAccess = false,
this.searchExtensionId,
this.selectedSearchFilter,
this.searchSource,
});
bool get hasContent =>
@@ -83,6 +87,8 @@ class TrackState {
String? searchExtensionId,
String? selectedSearchFilter,
bool clearSelectedSearchFilter = false,
String? searchSource,
bool clearSearchSource = false,
}) {
return TrackState(
tracks: tracks ?? this.tracks,
@@ -108,6 +114,9 @@ class TrackState {
selectedSearchFilter: clearSelectedSearchFilter
? null
: (selectedSearchFilter ?? this.selectedSearchFilter),
searchSource: clearSearchSource
? null
: (searchSource ?? this.searchSource),
);
}
}
@@ -278,7 +287,9 @@ class TrackNotifier extends Notifier<TrackState> {
playlistName: type == 'playlist'
? result['name'] as String?
: null,
coverUrl: result['cover_url'] as String?,
coverUrl: normalizeCoverReference(
result['cover_url']?.toString(),
),
searchExtensionId: extensionId,
);
return;
@@ -305,10 +316,12 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: false,
artistId: artistData['id'] as String?,
artistName: artistData['name'] as String?,
coverUrl:
artistData['image_url'] as String? ??
artistData['images'] as String?,
headerImageUrl: artistData['header_image'] as String?,
coverUrl: normalizeRemoteHttpUrl(
(artistData['image_url'] ?? artistData['images'])?.toString(),
),
headerImageUrl: normalizeRemoteHttpUrl(
artistData['header_image']?.toString(),
),
monthlyListeners: artistData['listeners'] as int?,
artistAlbums: albums,
artistTopTracks: topTracks.isNotEmpty ? topTracks : null,
@@ -349,7 +362,7 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: false,
albumId: id,
albumName: albumInfo['name'] as String?,
coverUrl: albumInfo['images'] as String?,
coverUrl: normalizeRemoteHttpUrl(albumInfo['images']?.toString()),
);
_preWarmCacheForTracks(tracks);
} else if (type == 'playlist') {
@@ -363,7 +376,9 @@ class TrackNotifier extends Notifier<TrackState> {
tracks: tracks,
isLoading: false,
playlistName: playlistInfo['name'] as String?,
coverUrl: playlistInfo['images'] as String?,
coverUrl: normalizeRemoteHttpUrl(
playlistInfo['images']?.toString(),
),
);
_preWarmCacheForTracks(tracks);
} else if (type == 'artist') {
@@ -377,7 +392,78 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: false,
artistId: artistInfo['id'] as String?,
artistName: artistInfo['name'] as String?,
coverUrl: artistInfo['images'] as String?,
coverUrl: normalizeRemoteHttpUrl(artistInfo['images']?.toString()),
artistAlbums: albums,
);
}
return;
}
if (url.contains('qobuz.com') || url.startsWith('qobuzapp://')) {
_log.i('Detected Qobuz URL, parsing...');
final parsed = await PlatformBridge.parseQobuzUrl(url);
if (!_isRequestValid(requestId)) return;
final type = parsed['type'] as String;
final id = parsed['id'] as String;
final metadata = await PlatformBridge.getQobuzMetadata(type, id);
if (!_isRequestValid(requestId)) return;
if (type == 'track') {
final trackData = metadata['track'] as Map<String, dynamic>;
final track = _parseTrack(trackData);
state = TrackState(
tracks: [track],
isLoading: false,
coverUrl: track.coverUrl,
);
} else if (type == 'album') {
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
state = TrackState(
tracks: tracks,
isLoading: false,
albumId: 'qobuz:$id',
albumName: albumInfo['name'] as String?,
coverUrl: normalizeRemoteHttpUrl(albumInfo['images']?.toString()),
);
_preWarmCacheForTracks(tracks);
} else if (type == 'playlist') {
final playlistInfo =
metadata['playlist_info'] as Map<String, dynamic>;
final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
final owner = playlistInfo['owner'] as Map<String, dynamic>?;
final playlistName =
(playlistInfo['name'] ?? owner?['name']) as String?;
final coverUrl = normalizeRemoteHttpUrl(
(playlistInfo['images'] ?? owner?['images'])?.toString(),
);
state = TrackState(
tracks: tracks,
isLoading: false,
playlistName: playlistName,
coverUrl: coverUrl,
);
_preWarmCacheForTracks(tracks);
} else if (type == 'artist') {
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
final albumsList = metadata['albums'] as List<dynamic>;
final albums = albumsList
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
.toList();
state = TrackState(
tracks: [],
isLoading: false,
artistId: artistInfo['id'] as String?,
artistName: artistInfo['name'] as String?,
coverUrl: normalizeRemoteHttpUrl(artistInfo['images']?.toString()),
artistAlbums: albums,
);
}
@@ -392,66 +478,78 @@ class TrackNotifier extends Notifier<TrackState> {
final type = parsed['type'] as String;
final id = parsed['id'] as String;
_log.i('Tidal URL parsed: type=$type, id=$id');
final metadata = await PlatformBridge.getTidalMetadata(type, id);
if (!_isRequestValid(requestId)) return;
// For track URLs, convert to Spotify/Deezer and fetch metadata from there
if (type == 'track') {
try {
_log.i('Converting Tidal track to Spotify/Deezer via SongLink...');
final conversion = await PlatformBridge.convertTidalToSpotifyDeezer(
url,
);
if (!_isRequestValid(requestId)) return;
final spotifyUrl = conversion['spotify_url'] as String?;
final deezerUrl = conversion['deezer_url'] as String?;
if (spotifyUrl != null && spotifyUrl.isNotEmpty) {
_log.i('Found Spotify URL: $spotifyUrl, fetching metadata...');
final metadata =
await PlatformBridge.getSpotifyMetadataWithFallback(
spotifyUrl,
);
if (!_isRequestValid(requestId)) return;
final trackData = metadata['track'] as Map<String, dynamic>;
final track = _parseTrack(trackData);
state = TrackState(
tracks: [track],
isLoading: false,
coverUrl: track.coverUrl,
);
return;
} else if (deezerUrl != null && deezerUrl.isNotEmpty) {
_log.i('Found Deezer URL: $deezerUrl, fetching metadata...');
final deezerParsed = await PlatformBridge.parseDeezerUrl(
deezerUrl,
);
final metadata = await PlatformBridge.getDeezerMetadata(
'track',
deezerParsed['id'] as String,
);
if (!_isRequestValid(requestId)) return;
final trackData = metadata['track'] as Map<String, dynamic>;
final track = _parseTrack(trackData);
state = TrackState(
tracks: [track],
isLoading: false,
coverUrl: track.coverUrl,
);
return;
}
} catch (e) {
_log.w('Failed to convert Tidal URL via SongLink: $e');
}
final trackData = metadata['track'] as Map<String, dynamic>;
final track = _parseTrack(trackData);
state = TrackState(
tracks: [track],
isLoading: false,
coverUrl: track.coverUrl,
);
} else if (type == 'album') {
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
state = TrackState(
tracks: tracks,
isLoading: false,
albumId: 'tidal:$id',
albumName: albumInfo['name'] as String?,
coverUrl: normalizeRemoteHttpUrl(albumInfo['images']?.toString()),
);
_preWarmCacheForTracks(tracks);
} else if (type == 'playlist') {
final playlistInfo =
metadata['playlist_info'] as Map<String, dynamic>;
final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
final owner = playlistInfo['owner'] as Map<String, dynamic>?;
final playlistName =
(playlistInfo['name'] ?? owner?['name']) as String?;
final coverUrl = normalizeRemoteHttpUrl(
(playlistInfo['images'] ?? owner?['images'])?.toString(),
);
state = TrackState(
tracks: tracks,
isLoading: false,
playlistName: playlistName,
coverUrl: coverUrl,
);
_preWarmCacheForTracks(tracks);
} else if (type == 'artist') {
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
final albumsList = metadata['albums'] as List<dynamic>;
final albums = albumsList
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
.toList();
state = TrackState(
tracks: [],
isLoading: false,
artistId: artistInfo['id'] as String?,
artistName: artistInfo['name'] as String?,
coverUrl: normalizeRemoteHttpUrl(artistInfo['images']?.toString()),
artistAlbums: albums,
);
}
return;
}
// For album/artist/playlist, not yet supported
// If URL doesn't match any known service, it's unrecognized
final isSpotifyUrl =
url.contains('open.spotify.com') ||
url.contains('spotify.link') ||
url.startsWith('spotify:');
if (!isSpotifyUrl) {
state = TrackState(
isLoading: false,
error:
'Tidal $type links are not fully supported yet. Only track links work via SongLink conversion.',
error: 'url_not_recognized',
hasSearchText: state.hasSearchText,
);
return;
@@ -491,7 +589,7 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: false,
albumId: parsed['id'] as String?,
albumName: albumInfo['name'] as String?,
coverUrl: albumInfo['images'] as String?,
coverUrl: normalizeRemoteHttpUrl(albumInfo['images']?.toString()),
);
_preWarmCacheForTracks(tracks);
} else if (type == 'playlist') {
@@ -501,11 +599,16 @@ class TrackNotifier extends Notifier<TrackState> {
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
final owner = playlistInfo['owner'] as Map<String, dynamic>?;
final playlistName =
(playlistInfo['name'] ?? owner?['name']) as String?;
final coverUrl = normalizeRemoteHttpUrl(
(playlistInfo['images'] ?? owner?['images'])?.toString(),
);
state = TrackState(
tracks: tracks,
isLoading: false,
playlistName: owner?['name'] as String?,
coverUrl: owner?['images'] as String?,
playlistName: playlistName,
coverUrl: coverUrl,
);
_preWarmCacheForTracks(tracks);
} else if (type == 'artist') {
@@ -519,7 +622,7 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: false,
artistId: artistInfo['id'] as String?,
artistName: artistInfo['name'] as String?,
coverUrl: artistInfo['images'] as String?,
coverUrl: normalizeRemoteHttpUrl(artistInfo['images']?.toString()),
artistAlbums: albums,
);
}
@@ -535,8 +638,8 @@ class TrackNotifier extends Notifier<TrackState> {
Future<void> search(
String query, {
String? metadataSource,
String? filterOverride,
String? builtInSearchProvider,
}) async {
final requestId = ++_currentRequestId;
@@ -556,65 +659,72 @@ class TrackNotifier extends Notifier<TrackState> {
final hasActiveMetadataExtensions = extensionState.extensions.any(
(e) => e.enabled && e.hasMetadataProvider,
);
final searchProvider = settings.searchProvider;
final useExtensions =
settings.useExtensionProviders &&
hasActiveMetadataExtensions &&
searchProvider != null &&
searchProvider.isNotEmpty;
final includeExtensions =
settings.useExtensionProviders && hasActiveMetadataExtensions;
final source = metadataSource ?? 'deezer';
// Determine the effective search provider
final effectiveProvider = builtInSearchProvider ?? 'deezer';
_log.i(
'Search started: source=$source, query="$query", useExtensions=$useExtensions, filter=$currentFilter',
'Search started: provider=$effectiveProvider, query="$query", includeExtensions=$includeExtensions, filter=$currentFilter',
);
Map<String, dynamic> results;
List<Track> extensionTracks = [];
List<Map<String, dynamic>> metadataTrackResults = [];
if (useExtensions) {
// Only use metadata providers for Deezer search (default behavior)
if (effectiveProvider == 'deezer') {
try {
_log.d('Calling extension search API...');
final extResults = await PlatformBridge.searchTracksWithExtensions(
query,
limit: 20,
_log.d('Calling metadata provider search API...');
metadataTrackResults =
await PlatformBridge.searchTracksWithMetadataProviders(
query,
limit: 20,
includeExtensions: includeExtensions,
);
_log.i(
'Metadata providers returned ${metadataTrackResults.length} tracks',
);
_log.i('Extensions returned ${extResults.length} tracks');
for (final t in extResults) {
try {
extensionTracks.add(_parseSearchTrack(t));
} catch (e) {
_log.e('Failed to parse extension track: $e', e);
}
}
} catch (e) {
_log.w('Extension search failed, falling back to built-in: $e');
_log.w(
'Metadata provider search failed, falling back to Deezer tracks: $e',
);
}
}
if (source == 'deezer') {
_log.d('Calling Deezer search API...');
results = await PlatformBridge.searchDeezerAll(
query,
trackLimit: 20,
artistLimit: 2,
filter: currentFilter,
);
_log.i(
'Deezer returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists, ${(results['albums'] as List?)?.length ?? 0} albums',
);
} else {
_log.d('Calling Spotify search API...');
results = await PlatformBridge.searchSpotifyAll(
query,
trackLimit: 20,
artistLimit: 2,
);
_log.i(
'Spotify returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists',
);
// Call the appropriate search API
switch (effectiveProvider) {
case 'tidal':
_log.d('Calling Tidal search API...');
results = await PlatformBridge.searchTidalAll(
query,
trackLimit: 20,
artistLimit: 2,
filter: currentFilter,
);
break;
case 'qobuz':
_log.d('Calling Qobuz search API...');
results = await PlatformBridge.searchQobuzAll(
query,
trackLimit: 20,
artistLimit: 2,
filter: currentFilter,
);
break;
default:
_log.d('Calling Deezer search API...');
results = await PlatformBridge.searchDeezerAll(
query,
trackLimit: 20,
artistLimit: 2,
filter: currentFilter,
);
break;
}
_log.i(
'$effectiveProvider returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists, ${(results['albums'] as List?)?.length ?? 0} albums',
);
if (!_isRequestValid(requestId)) {
_log.w('Search request cancelled (requestId=$requestId)');
@@ -624,32 +734,20 @@ class TrackNotifier extends Notifier<TrackState> {
final trackList = results['tracks'] as List<dynamic>? ?? [];
final artistList = results['artists'] as List<dynamic>? ?? [];
final albumList = results['albums'] as List<dynamic>? ?? [];
final trackSearchResults = metadataTrackResults.isNotEmpty
? metadataTrackResults
: trackList.whereType<Map<String, dynamic>>().toList();
_log.d(
'Raw results: ${trackList.length} tracks, ${artistList.length} artists, ${albumList.length} albums',
'Raw results: ${trackSearchResults.length} tracks, ${artistList.length} artists, ${albumList.length} albums',
);
final tracks = <Track>[];
tracks.addAll(extensionTracks);
final existingIsrcs = extensionTracks
.where((t) => t.isrc != null && t.isrc!.isNotEmpty)
.map((t) => t.isrc!)
.toSet();
for (int i = 0; i < trackList.length; i++) {
final t = trackList[i];
for (int i = 0; i < trackSearchResults.length; i++) {
final t = trackSearchResults[i];
try {
if (t is Map<String, dynamic>) {
final track = _parseSearchTrack(t);
if (track.isrc != null && existingIsrcs.contains(track.isrc)) {
continue;
}
tracks.add(track);
} else {
_log.w('Track[$i] is not a Map: ${t.runtimeType}');
}
tracks.add(_parseSearchTrack(t));
} catch (e) {
_log.e('Failed to parse track[$i]: $e', e);
}
@@ -699,7 +797,7 @@ class TrackNotifier extends Notifier<TrackState> {
}
_log.i(
'Search complete: ${tracks.length} tracks (${extensionTracks.length} from extensions), ${artists.length} artists, ${albums.length} albums, ${playlists.length} playlists parsed successfully',
'Search complete: ${tracks.length} tracks, ${artists.length} artists, ${albums.length} albums, ${playlists.length} playlists parsed successfully',
);
state = TrackState(
@@ -711,6 +809,8 @@ class TrackNotifier extends Notifier<TrackState> {
hasSearchText: state.hasSearchText,
isShowingRecentAccess: state.isShowingRecentAccess,
selectedSearchFilter: currentFilter, // Preserve filter in results
searchSource:
effectiveProvider, // Track which service was used for search
);
} catch (e, stackTrace) {
if (!_isRequestValid(requestId)) return;
@@ -818,6 +918,7 @@ class TrackNotifier extends Notifier<TrackState> {
discNumber: track.discNumber,
releaseDate: track.releaseDate,
albumType: track.albumType,
totalTracks: track.totalTracks,
source: track.source,
availability: ServiceAvailability(
tidal: availability['tidal'] as bool? ?? false,
@@ -885,20 +986,24 @@ class TrackNotifier extends Notifier<TrackState> {
Track _parseTrack(Map<String, dynamic> data) {
final durationMs = _extractDurationMs(data);
final spotifyId = (data['spotify_id'] ?? '').toString();
final nativeId = (data['id'] ?? '').toString();
return Track(
id: data['spotify_id'] as String? ?? '',
id: spotifyId.isNotEmpty ? spotifyId : nativeId,
name: data['name'] as String? ?? '',
artistName: data['artists'] as String? ?? '',
albumName: data['album_name'] as String? ?? '',
albumArtist: data['album_artist'] as String?,
artistId: (data['artist_id'] ?? data['artistId'])?.toString(),
albumId: data['album_id']?.toString(),
coverUrl: data['images'] as String?,
coverUrl: normalizeCoverReference(data['images']?.toString()),
isrc: data['isrc'] as String?,
duration: (durationMs / 1000).round(),
trackNumber: data['track_number'] as int?,
discNumber: data['disc_number'] as int?,
releaseDate: data['release_date'] as String?,
albumType: data['album_type'] as String?,
totalTracks: data['total_tracks'] as int?,
);
}
@@ -906,25 +1011,32 @@ 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(),
albumArtist: data['album_artist']?.toString(),
artistId: (data['artist_id'] ?? data['artistId'])?.toString(),
albumId: data['album_id']?.toString(),
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
coverUrl: normalizeCoverReference(
(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(),
source:
source ??
data['source']?.toString() ??
data['provider_id']?.toString(),
totalTracks: data['total_tracks'] as int?,
source: effectiveSource,
albumType: data['album_type']?.toString(),
itemType: itemType,
);
@@ -962,7 +1074,9 @@ class TrackNotifier extends Notifier<TrackState> {
name: data['name'] as String? ?? '',
releaseDate: data['release_date'] as String? ?? '',
totalTracks: data['total_tracks'] as int? ?? 0,
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
coverUrl: normalizeCoverReference(
(data['cover_url'] ?? data['images'])?.toString(),
),
albumType: data['album_type'] as String? ?? 'album',
artists: data['artists'] as String? ?? '',
providerId: data['provider_id']?.toString(),
@@ -973,7 +1087,7 @@ class TrackNotifier extends Notifier<TrackState> {
return SearchArtist(
id: data['id'] as String? ?? '',
name: data['name'] as String? ?? '',
imageUrl: data['images'] as String?,
imageUrl: normalizeRemoteHttpUrl(data['images']?.toString()),
followers: data['followers'] as int? ?? 0,
popularity: data['popularity'] as int? ?? 0,
);
@@ -984,7 +1098,7 @@ class TrackNotifier extends Notifier<TrackState> {
id: data['id'] as String? ?? '',
name: data['name'] as String? ?? '',
artists: data['artists'] as String? ?? '',
imageUrl: data['images'] as String?,
imageUrl: normalizeRemoteHttpUrl(data['images']?.toString()),
releaseDate: data['release_date'] as String?,
totalTracks: data['total_tracks'] as int? ?? 0,
albumType: data['album_type'] as String? ?? 'album',
@@ -996,7 +1110,7 @@ class TrackNotifier extends Notifier<TrackState> {
id: data['id'] as String? ?? '',
name: data['name'] as String? ?? '',
owner: data['owner'] as String? ?? '',
imageUrl: data['images'] as String?,
imageUrl: normalizeRemoteHttpUrl(data['images']?.toString()),
totalTracks: data['total_tracks'] as int? ?? 0,
);
}
+98 -28
View File
@@ -11,6 +11,7 @@ import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/providers/playback_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart';
import 'package:spotiflac_android/providers/library_collections_provider.dart';
@@ -81,16 +82,23 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
_scrollController.addListener(_onScroll);
WidgetsBinding.instance.addPostFrameCallback((_) {
// Use extensionId if available, otherwise detect from albumId prefix
final providerId =
widget.extensionId ??
(widget.albumId.startsWith('deezer:') ? 'deezer' : 'spotify');
(() {
if (widget.albumId.startsWith('deezer:')) return 'deezer';
if (widget.albumId.startsWith('qobuz:')) return 'qobuz';
if (widget.albumId.startsWith('tidal:')) return 'tidal';
return 'spotify';
})();
ref
.read(recentAccessProvider.notifier)
.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,
);
@@ -129,9 +137,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)
@@ -175,6 +181,12 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
'album',
deezerAlbumId,
);
} else if (widget.albumId.startsWith('qobuz:')) {
final qobuzAlbumId = widget.albumId.replaceFirst('qobuz:', '');
metadata = await PlatformBridge.getQobuzMetadata('album', qobuzAlbumId);
} else if (widget.albumId.startsWith('tidal:')) {
final tidalAlbumId = widget.albumId.replaceFirst('tidal:', '');
metadata = await PlatformBridge.getTidalMetadata('album', tidalAlbumId);
} else {
final url = 'https://open.spotify.com/album/${widget.albumId}';
metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
@@ -218,12 +230,14 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
artistId:
(data['artist_id'] ?? data['artistId'])?.toString() ?? _artistId,
albumId: data['album_id']?.toString() ?? widget.albumId,
coverUrl: data['images'] as String?,
coverUrl: normalizeCoverReference(data['images']?.toString()),
isrc: data['isrc'] as String?,
duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(),
trackNumber: data['track_number'] as int?,
discNumber: data['disc_number'] as int?,
releaseDate: data['release_date'] as String?,
albumType: data['album_type'] as String?,
totalTracks: data['total_tracks'] as int?,
);
}
@@ -270,7 +284,11 @@ 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(
@@ -503,7 +521,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());
}
@@ -558,37 +575,82 @@ 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;
@@ -617,7 +679,9 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
size: 22,
color: allLoved ? Colors.redAccent : Colors.white,
),
tooltip: allLoved ? 'Remove from Loved' : 'Love All',
tooltip: allLoved
? context.l10n.trackOptionRemoveFromLoved
: context.l10n.tooltipLoveAll,
padding: EdgeInsets.zero,
),
);
@@ -640,7 +704,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
? null
: () => showAddTracksToPlaylistSheet(context, ref, _tracks!),
icon: const Icon(Icons.add, size: 22, color: Colors.white),
tooltip: 'Add to Playlist',
tooltip: context.l10n.tooltipAddToPlaylist,
padding: EdgeInsets.zero,
),
);
@@ -658,7 +722,11 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Removed ${tracks.length} tracks from Loved')),
SnackBar(
content: Text(
context.l10n.snackbarRemovedTracksFromLoved(tracks.length),
),
),
);
}
} else {
@@ -671,7 +739,9 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Added $addedCount tracks to Loved')),
SnackBar(
content: Text(context.l10n.snackbarAddedTracksToLoved(addedCount)),
),
);
}
}
+234 -28
View File
@@ -14,6 +14,7 @@ import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/providers/playback_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/screens/album_screen.dart';
import 'package:spotiflac_android/screens/home_tab.dart'
show ExtensionAlbumScreen;
@@ -38,12 +39,14 @@ class _ArtistCache {
static void set(
String artistId, {
required List<ArtistAlbum> albums,
List<ArtistAlbum>? releases,
List<Track>? topTracks,
String? headerImageUrl,
int? monthlyListeners,
}) {
_cache[artistId] = _CacheEntry(
albums: albums,
releases: releases,
topTracks: topTracks,
headerImageUrl: headerImageUrl,
monthlyListeners: monthlyListeners,
@@ -54,6 +57,7 @@ class _ArtistCache {
class _CacheEntry {
final List<ArtistAlbum> albums;
final List<ArtistAlbum>? releases;
final List<Track>? topTracks;
final String? headerImageUrl;
final int? monthlyListeners;
@@ -61,6 +65,7 @@ class _CacheEntry {
_CacheEntry({
required this.albums,
this.releases,
this.topTracks,
this.headerImageUrl,
this.monthlyListeners,
@@ -97,6 +102,7 @@ class ArtistScreen extends ConsumerStatefulWidget {
class _ArtistScreenState extends ConsumerState<ArtistScreen> {
bool _isLoadingDiscography = false;
List<ArtistAlbum>? _albums;
List<ArtistAlbum>? _releases;
List<Track>? _topTracks;
String? _headerImageUrl;
int? _monthlyListeners;
@@ -104,6 +110,8 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
bool _showTitleInAppBar = false;
final ScrollController _scrollController = ScrollController();
final PageController _popularPageController = PageController();
int _popularCurrentPage = 0;
bool _isSelectionMode = false;
final Set<String> _selectedAlbumIds = {};
@@ -153,7 +161,12 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
WidgetsBinding.instance.addPostFrameCallback((_) {
final providerId =
widget.extensionId ??
(widget.artistId.startsWith('deezer:') ? 'deezer' : 'spotify');
(() {
if (widget.artistId.startsWith('deezer:')) return 'deezer';
if (widget.artistId.startsWith('qobuz:')) return 'qobuz';
if (widget.artistId.startsWith('tidal:')) return 'tidal';
return 'spotify';
})();
ref
.read(recentAccessProvider.notifier)
.recordArtistAccess(
@@ -169,6 +182,11 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
_topTracks = widget.topTracks;
_headerImageUrl = widget.headerImageUrl;
_monthlyListeners = widget.monthlyListeners;
if ((_albums == null || _albums!.isEmpty) ||
(_topTracks == null || _topTracks!.isEmpty)) {
_fetchDiscography();
}
return;
}
@@ -185,6 +203,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
}
} else if (cached != null) {
_albums = cached.albums;
_releases = cached.releases;
_topTracks = cached.topTracks;
_headerImageUrl = cached.headerImageUrl;
_monthlyListeners = cached.monthlyListeners;
@@ -209,6 +228,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
_popularPageController.dispose();
super.dispose();
}
@@ -216,6 +236,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
setState(() => _isLoadingDiscography = true);
try {
List<ArtistAlbum> albums;
List<ArtistAlbum>? releases;
List<Track>? topTracks;
String? headerImage;
int? listeners;
@@ -230,6 +251,65 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
albums = albumsList
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
.toList();
} else if (widget.artistId.startsWith('qobuz:')) {
final qobuzArtistId = widget.artistId.replaceFirst('qobuz:', '');
final metadata = await PlatformBridge.getQobuzMetadata(
'artist',
qobuzArtistId,
);
final albumsList = metadata['albums'] as List<dynamic>;
albums = albumsList
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
.toList();
final artistInfo = metadata['artist_info'] as Map<String, dynamic>?;
headerImage = artistInfo?['images'] as String?;
} else if (widget.artistId.startsWith('tidal:')) {
final tidalArtistId = widget.artistId.replaceFirst('tidal:', '');
final metadata = await PlatformBridge.getTidalMetadata(
'artist',
tidalArtistId,
);
final albumsList = metadata['albums'] as List<dynamic>;
albums = albumsList
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
.toList();
final artistInfo = metadata['artist_info'] as Map<String, dynamic>?;
headerImage = artistInfo?['images'] as String?;
} else if (widget.extensionId != null && widget.extensionId!.isNotEmpty) {
final result = await PlatformBridge.getArtistWithExtension(
widget.extensionId!,
widget.artistId,
);
if (result == null) {
throw Exception('Failed to load artist from extension');
}
final artistData = result;
final albumsList = artistData['albums'] as List<dynamic>? ?? [];
albums = albumsList
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
.toList();
final releasesList = artistData['releases'] as List<dynamic>? ?? [];
if (releasesList.isNotEmpty) {
releases = releasesList
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
.toList();
}
final topTracksList = artistData['top_tracks'] as List<dynamic>? ?? [];
if (topTracksList.isNotEmpty) {
topTracks = topTracksList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
}
headerImage =
artistData['header_image'] as String? ??
artistData['cover_url'] as String? ??
artistData['image_url'] as String?;
listeners = artistData['listeners'] as int?;
} else {
final url = 'https://open.spotify.com/artist/${widget.artistId}';
final result = await PlatformBridge.handleURLWithExtension(url);
@@ -270,6 +350,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
_ArtistCache.set(
widget.artistId,
albums: albums,
releases: releases,
topTracks: topTracks,
headerImageUrl: finalHeaderImage,
monthlyListeners: finalListeners,
@@ -278,6 +359,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
if (mounted) {
setState(() {
_albums = albums;
_releases = releases;
_topTracks = topTracks;
_headerImageUrl = finalHeaderImage;
_monthlyListeners = finalListeners;
@@ -294,7 +376,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
}
}
Track _parseTrack(Map<String, dynamic> data) {
Track _parseTrack(Map<String, dynamic> data, {ArtistAlbum? album}) {
int durationMs = 0;
final durationValue = data['duration_ms'];
if (durationValue is int) {
@@ -303,36 +385,52 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
durationMs = durationValue.toInt();
}
final spotifyId = (data['spotify_id'] ?? '').toString();
final nativeId = (data['id'] ?? '').toString();
return Track(
id: (data['spotify_id'] ?? data['id'] ?? '').toString(),
id: spotifyId.isNotEmpty ? spotifyId : nativeId,
name: (data['name'] ?? '').toString(),
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
albumName: (data['album_name'] ?? data['album'] ?? '').toString(),
albumArtist: data['album_artist']?.toString(),
albumName: (data['album_name'] ?? data['album'] ?? album?.name ?? '')
.toString(),
albumArtist: data['album_artist']?.toString() ?? widget.artistName,
artistId:
(data['artist_id'] ?? data['artistId'])?.toString() ??
widget.artistId,
albumId: data['album_id']?.toString(),
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
albumId: data['album_id']?.toString() ?? album?.id,
coverUrl: normalizeCoverReference(
(data['cover_url'] ?? data['images'] ?? album?.coverUrl)?.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(),
source: data['provider_id']?.toString(),
albumType: data['album_type']?.toString() ?? album?.albumType,
totalTracks: data['total_tracks'] as int? ?? album?.totalTracks,
source: data['provider_id']?.toString() ?? widget.extensionId,
);
}
ArtistAlbum _parseArtistAlbum(Map<String, dynamic> data) {
final totalTracksValue = data['total_tracks'];
final totalTracks = totalTracksValue is int
? totalTracksValue
: int.tryParse(totalTracksValue?.toString() ?? '') ?? 0;
return ArtistAlbum(
id: data['id'] as String? ?? '',
name: data['name'] as String? ?? '',
releaseDate: data['release_date'] as String? ?? '',
totalTracks: data['total_tracks'] as int? ?? 0,
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
albumType: data['album_type'] as String? ?? 'album',
artists: data['artists'] as String? ?? '',
providerId: data['provider_id']?.toString(),
name: (data['name'] ?? data['title'] ?? '').toString(),
releaseDate: (data['release_date'] ?? '').toString(),
totalTracks: totalTracks,
coverUrl: normalizeCoverReference(
(data['cover_url'] ?? data['images'] ?? data['cover_art'])?.toString(),
),
albumType: (data['album_type'] ?? data['type'] ?? 'album').toString(),
artists: (data['artists'] ?? data['artist'] ?? widget.artistName)
.toString(),
providerId: data['provider_id']?.toString() ?? widget.extensionId,
);
}
@@ -343,7 +441,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
.where((a) => a.albumType == 'album')
.toList(growable: false);
_singlesBucket = albums
.where((a) => a.albumType == 'single')
.where((a) => a.albumType == 'single' || a.albumType == 'ep')
.toList(growable: false);
_compilationsBucket = albums
.where((a) => a.albumType == 'compilation')
@@ -355,6 +453,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
final colorScheme = Theme.of(context).colorScheme;
final albums = _albums ?? [];
_ensureAlbumBuckets(albums);
final releases = _releases ?? const <ArtistAlbum>[];
final albumsOnly = _albumsOnlyBucket;
final singles = _singlesBucket;
final compilations = _compilationsBucket;
@@ -400,6 +499,14 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
SliverToBoxAdapter(
child: _buildPopularSection(colorScheme),
),
if (releases.isNotEmpty)
SliverToBoxAdapter(
child: _buildAlbumSection(
'Releases',
releases,
colorScheme,
),
),
if (albumsOnly.isNotEmpty)
SliverToBoxAdapter(
child: _buildAlbumSection(
@@ -414,6 +521,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
context.l10n.artistSingles,
singles,
colorScheme,
showTypeBadge: true,
),
),
if (compilations.isNotEmpty)
@@ -668,7 +776,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
List<ArtistAlbum> albums,
) {
final albumsOnly = albums.where((a) => a.albumType == 'album').toList();
final singles = albums.where((a) => a.albumType == 'single').toList();
final singles = albums
.where((a) => a.albumType == 'single' || a.albumType == 'ep')
.toList();
final totalTracks = albums.fold<int>(0, (sum, a) => sum + a.totalTracks);
final albumTracks = albumsOnly.fold<int>(
@@ -939,7 +1049,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
if (result != null && result['tracks'] != null) {
final tracksList = result['tracks'] as List<dynamic>;
return tracksList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.map((t) => _parseTrack(t as Map<String, dynamic>, album: album))
.toList();
}
} else if (album.id.startsWith('deezer:')) {
@@ -954,13 +1064,31 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
.map((t) => _parseTrackFromDeezer(t as Map<String, dynamic>, album))
.toList();
}
} else if (album.id.startsWith('qobuz:')) {
final qobuzId = album.id.replaceFirst('qobuz:', '');
final metadata = await PlatformBridge.getQobuzMetadata('album', qobuzId);
if (metadata['track_list'] != null) {
final tracksList = metadata['track_list'] as List<dynamic>;
return tracksList
.map((t) => _parseTrack(t as Map<String, dynamic>, album: album))
.toList();
}
} else if (album.id.startsWith('tidal:')) {
final tidalId = album.id.replaceFirst('tidal:', '');
final metadata = await PlatformBridge.getTidalMetadata('album', tidalId);
if (metadata['track_list'] != null) {
final tracksList = metadata['track_list'] as List<dynamic>;
return tracksList
.map((t) => _parseTrack(t as Map<String, dynamic>, album: album))
.toList();
}
} else {
final url = 'https://open.spotify.com/album/${album.id}';
final result = await PlatformBridge.handleURLWithExtension(url);
if (result != null && result['tracks'] != null) {
final tracksList = result['tracks'] as List<dynamic>;
return tracksList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.map((t) => _parseTrack(t as Map<String, dynamic>, album: album))
.toList();
}
@@ -969,7 +1097,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
if (metadata['tracks'] != null) {
final tracksList = metadata['tracks'] as List<dynamic>;
return tracksList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.map((t) => _parseTrack(t as Map<String, dynamic>, album: album))
.toList();
}
}
@@ -1003,6 +1131,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
discNumber: data['disk_number'] as int? ?? data['disc_number'] as int?,
releaseDate: album.releaseDate,
albumType: album.albumType,
totalTracks: album.totalTracks,
);
}
@@ -1203,7 +1332,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
return const SizedBox.shrink();
}
final tracks = _topTracks!.take(5).toList();
final tracks = _topTracks!;
const tracksPerPage = 5;
final pageCount = (tracks.length / tracksPerPage).ceil();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -1217,11 +1348,60 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
...tracks.asMap().entries.map((entry) {
final index = entry.key;
final track = entry.value;
return _buildPopularTrackItem(index + 1, track, colorScheme);
}),
SizedBox(
height: tracksPerPage * 64.0,
child: PageView.builder(
controller: _popularPageController,
itemCount: pageCount,
onPageChanged: (page) {
setState(() {
_popularCurrentPage = page;
});
},
itemBuilder: (context, pageIndex) {
final startIndex = pageIndex * tracksPerPage;
final endIndex = (startIndex + tracksPerPage).clamp(
0,
tracks.length,
);
final pageTracks = tracks.sublist(startIndex, endIndex);
return Column(
children: pageTracks.asMap().entries.map((entry) {
final globalIndex = startIndex + entry.key;
return _buildPopularTrackItem(
globalIndex + 1,
entry.value,
colorScheme,
);
}).toList(),
);
},
),
),
if (pageCount > 1)
Center(
child: Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(pageCount, (index) {
final isActive = _popularCurrentPage == index;
return Container(
margin: const EdgeInsets.symmetric(horizontal: 3),
width: isActive ? 8 : 6,
height: isActive ? 8 : 6,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isActive
? colorScheme.primary
: colorScheme.onSurfaceVariant.withValues(alpha: 0.3),
),
);
}),
),
),
),
],
);
}
@@ -1523,8 +1703,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
Widget _buildAlbumSection(
String title,
List<ArtistAlbum> albums,
ColorScheme colorScheme,
) {
ColorScheme colorScheme, {
bool showTypeBadge = false,
}) {
final sectionHeight = _artistAlbumSectionHeight();
final tileSize = _artistAlbumTileSize();
@@ -1555,6 +1736,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
colorScheme,
tileSize: tileSize,
sectionHeight: sectionHeight,
showTypeBadge: showTypeBadge,
),
);
},
@@ -1569,6 +1751,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
ColorScheme colorScheme, {
required double tileSize,
required double sectionHeight,
bool showTypeBadge = false,
}) {
final isSelected = _selectedAlbumIds.contains(album.id);
@@ -1681,6 +1864,29 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
: null,
),
),
if (showTypeBadge)
Positioned(
left: 6,
bottom: 6,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(4),
),
child: Text(
album.albumType == 'ep' ? 'EP' : 'Single',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.w600,
),
),
),
),
],
),
const SizedBox(height: 8),
+140 -58
View File
@@ -125,7 +125,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
(item.albumArtist != null && item.albumArtist!.isNotEmpty)
? item.albumArtist!
: item.artistName;
// Use lowercase for case-insensitive matching
final itemKey =
'${item.albumName.toLowerCase()}|${itemArtist.toLowerCase()}';
return itemKey == _albumLookupKey;
@@ -363,7 +362,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
if (tracks.isEmpty) {
return Scaffold(
appBar: AppBar(title: Text(widget.albumName)),
body: Center(child: Text('No tracks found for this album')),
body: Center(child: Text(context.l10n.noTracksFoundForAlbum)),
);
}
@@ -911,8 +910,45 @@ 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,
@@ -924,7 +960,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(
@@ -961,51 +996,73 @@ 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,
@@ -1058,12 +1115,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) {
@@ -1075,16 +1139,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(
@@ -1105,7 +1175,10 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
final total = selected.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';
@@ -1133,12 +1206,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
try {
final result = await PlatformBridge.readFileMetadata(item.filePath);
if (result['error'] == null) {
result.forEach((key, value) {
if (key == 'error' || value == null) return;
final v = value.toString().trim();
if (v.isEmpty) return;
metadata[key.toUpperCase()] = v;
});
mergePlatformMetadataForTagEmbed(target: metadata, source: result);
}
} catch (_) {}
await ensureLyricsMetadataForConversion(
@@ -1208,13 +1276,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,
+210 -37
View File
@@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -22,6 +23,7 @@ import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.da
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/screens/playlist_screen.dart';
import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart';
@@ -81,6 +83,37 @@ class _SearchResultBuckets {
});
}
const _homeHistoryPreviewLimit = 48;
class _HomeHistoryPreview {
final List<DownloadHistoryItem> items;
const _HomeHistoryPreview(this.items);
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is _HomeHistoryPreview && listEquals(items, other.items);
@override
int get hashCode => Object.hashAll(items);
}
final _homeHistoryPreviewProvider = Provider<List<DownloadHistoryItem>>((ref) {
final preview = ref.watch(
downloadHistoryProvider.select((s) {
final items = s.items;
if (items.length <= _homeHistoryPreviewLimit) {
return _HomeHistoryPreview(items);
}
return _HomeHistoryPreview(
items.take(_homeHistoryPreviewLimit).toList(growable: false),
);
}),
);
return preview.items;
});
_RecentAccessView _buildRecentAccessViewData(
List<RecentAccessItem> items,
List<DownloadHistoryItem> historyItems,
@@ -164,9 +197,7 @@ _RecentAccessView _buildRecentAccessViewData(
}
final recentAccessViewProvider = Provider<_RecentAccessView>((ref) {
final historyItems = ref.watch(
downloadHistoryProvider.select((s) => s.items),
);
final historyItems = ref.watch(_homeHistoryPreviewProvider);
final recentAccessItems = ref.watch(
recentAccessProvider.select((s) => s.items),
);
@@ -459,6 +490,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
if (searchProvider == null || searchProvider.isEmpty) return false;
// Built-in providers (tidal, qobuz) also support live search
if (_builtInSearchProviders.contains(searchProvider)) return true;
final extension = extState.extensions
.where((e) => e.id == searchProvider && e.enabled)
.firstOrNull;
@@ -516,6 +550,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
}
}
/// Built-in search providers that are not extensions
static const _builtInSearchProviders = {'tidal', 'qobuz'};
Future<void> _performSearch(String query, {String? filterOverride}) async {
final settings = ref.read(settingsProvider);
final extState = ref.read(extensionProvider);
@@ -528,9 +565,14 @@ class _HomeTabState extends ConsumerState<HomeTab>
if (_lastSearchQuery == searchKey) return;
_lastSearchQuery = searchKey;
final isBuiltInProvider =
searchProvider != null &&
_builtInSearchProviders.contains(searchProvider);
final isExtensionEnabled =
searchProvider != null &&
searchProvider.isNotEmpty &&
!isBuiltInProvider &&
extState.extensions.any((e) => e.id == searchProvider && e.enabled);
if (isExtensionEnabled) {
@@ -541,19 +583,25 @@ class _HomeTabState extends ConsumerState<HomeTab>
await ref
.read(trackProvider.notifier)
.customSearch(searchProvider, query, options: options);
} else {
if (searchProvider != null &&
searchProvider.isNotEmpty &&
!isExtensionEnabled) {
ref.read(settingsProvider.notifier).setSearchProvider(null);
}
} else if (isBuiltInProvider) {
// Use built-in Tidal or Qobuz search
await ref
.read(trackProvider.notifier)
.search(
query,
metadataSource: settings.metadataSource,
filterOverride: selectedFilter,
builtInSearchProvider: searchProvider,
);
} else {
if (searchProvider != null &&
searchProvider.isNotEmpty &&
!isExtensionEnabled &&
!isBuiltInProvider) {
ref.read(settingsProvider.notifier).setSearchProvider(null);
}
await ref
.read(trackProvider.notifier)
.search(query, filterOverride: selectedFilter);
}
ref.read(settingsProvider.notifier).setHasSearchedBefore();
}
@@ -583,12 +631,28 @@ class _HomeTabState extends ConsumerState<HomeTab>
if (url.isEmpty) return;
if (url.startsWith('http') || url.startsWith('spotify:')) {
await ref.read(trackProvider.notifier).fetchFromUrl(url);
_navigateToDetailIfNeeded();
final trackState = ref.read(trackProvider);
if (trackState.error != null && mounted) {
final l10n = context.l10n;
final errorMsg = trackState.error!;
final isRateLimit =
errorMsg.contains('429') ||
errorMsg.toLowerCase().contains('rate limit') ||
errorMsg.toLowerCase().contains('too many requests');
final displayMessage = errorMsg == 'url_not_recognized'
? l10n.errorUrlNotRecognizedMessage
: isRateLimit
? l10n.errorRateLimitedMessage
: l10n.errorUrlFetchFailed;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(displayMessage)));
ref.read(trackProvider.notifier).clear();
} else {
_navigateToDetailIfNeeded();
}
} else {
final settings = ref.read(settingsProvider);
await ref
.read(trackProvider.notifier)
.search(url, metadataSource: settings.metadataSource);
await ref.read(trackProvider.notifier).search(url);
}
ref.read(settingsProvider.notifier).setHasSearchedBefore();
}
@@ -676,6 +740,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
trackName: track.name,
artistName: track.artistName,
coverUrl: track.coverUrl,
recommendedService: trackState.searchSource,
onSelect: (quality, service) {
ref
.read(downloadQueueProvider.notifier)
@@ -804,7 +869,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
const SizedBox(height: 12),
CheckboxListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Skip already downloaded songs'),
title: Text(l10n.homeSkipAlreadyDownloaded),
value: skipDownloaded,
onChanged: (value) {
setDialogState(() {
@@ -975,9 +1040,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
final mediaQuery = MediaQuery.of(context);
final screenHeight = mediaQuery.size.height;
final topPadding = normalizedHeaderTopPadding(context);
final historyItems = ref.watch(
downloadHistoryProvider.select((s) => s.items),
);
final historyItems = ref.watch(_homeHistoryPreviewProvider);
final recentModeRequested = isShowingRecentAccess || isSearchFocused;
final showRecentAccess =
@@ -1262,7 +1325,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
(searchArtists != null && searchArtists.isNotEmpty) ||
(searchAlbums != null && searchAlbums.isNotEmpty) ||
(searchPlaylists != null && searchPlaylists.isNotEmpty) ||
isLoading;
isLoading ||
error != null;
return SliverMainAxisGroup(
slivers: _buildSearchResults(
@@ -1737,7 +1801,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
),
ListTile(
leading: Icon(Icons.album, color: colorScheme.onSurfaceVariant),
title: const Text('Go to Album'),
title: Text(context.l10n.homeGoToAlbum),
onTap: () {
Navigator.pop(context);
_navigateToTrackAlbum(item);
@@ -1809,9 +1873,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
),
);
} else {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Album info not available')));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.homeAlbumInfoUnavailable)),
);
}
}
@@ -2224,11 +2288,14 @@ class _HomeTabState extends ConsumerState<HomeTab>
}
Widget _buildErrorWidget(String error, ColorScheme colorScheme) {
final l10n = context.l10n;
final isRateLimit =
error.contains('429') ||
error.toLowerCase().contains('rate limit') ||
error.toLowerCase().contains('too many requests');
final isUrlNotRecognized = error == 'url_not_recognized';
if (isRateLimit) {
return Card(
elevation: 0,
@@ -2245,7 +2312,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Rate Limited',
l10n.errorRateLimited,
style: TextStyle(
color: colorScheme.onErrorContainer,
fontWeight: FontWeight.bold,
@@ -2253,7 +2320,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
),
const SizedBox(height: 4),
Text(
'Too many requests. Please wait a moment before searching again.',
l10n.errorRateLimitedMessage,
style: TextStyle(
color: colorScheme.onErrorContainer,
fontSize: 12,
@@ -2268,6 +2335,42 @@ class _HomeTabState extends ConsumerState<HomeTab>
);
}
if (isUrlNotRecognized) {
return Card(
elevation: 0,
color: colorScheme.errorContainer.withValues(alpha: 0.5),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(Icons.link_off, color: colorScheme.error),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.errorUrlNotRecognized,
style: TextStyle(
color: colorScheme.error,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
l10n.errorUrlNotRecognizedMessage,
style: TextStyle(color: colorScheme.error, fontSize: 12),
),
],
),
),
],
),
),
);
}
return Card(
elevation: 0,
color: colorScheme.errorContainer.withValues(alpha: 0.5),
@@ -2279,7 +2382,10 @@ class _HomeTabState extends ConsumerState<HomeTab>
Icon(Icons.error_outline, color: colorScheme.error),
const SizedBox(width: 12),
Expanded(
child: Text(error, style: TextStyle(color: colorScheme.error)),
child: Text(
l10n.errorUrlFetchFailed,
style: TextStyle(color: colorScheme.error),
),
),
],
),
@@ -2687,6 +2793,14 @@ class _HomeTabState extends ConsumerState<HomeTab>
}
if (searchProvider != null && searchProvider.isNotEmpty) {
// Check built-in providers first
if (searchProvider == 'tidal') {
return 'Search with Tidal...';
}
if (searchProvider == 'qobuz') {
return 'Search with Qobuz...';
}
final ext = extState.extensions
.where((e) => e.id == searchProvider)
.firstOrNull;
@@ -2907,9 +3021,6 @@ class _SearchProviderDropdown extends ConsumerWidget {
final currentProvider = ref.watch(
settingsProvider.select((s) => s.searchProvider),
);
final metadataSource = ref.watch(
settingsProvider.select((s) => s.metadataSource),
);
final extensions = ref.watch(extensionProvider.select((s) => s.extensions));
final colorScheme = Theme.of(context).colorScheme;
@@ -2924,6 +3035,11 @@ class _SearchProviderDropdown extends ConsumerWidget {
.firstOrNull;
}
// Check if current provider is a built-in provider (tidal/qobuz)
const builtInProviders = {'tidal', 'qobuz'};
final isBuiltInProvider =
currentProvider != null && builtInProviders.contains(currentProvider);
IconData displayIcon = Icons.search;
String? iconPath;
if (currentExt != null) {
@@ -2931,10 +3047,8 @@ class _SearchProviderDropdown extends ConsumerWidget {
if (currentExt.searchBehavior?.icon != null) {
displayIcon = _getIconFromName(currentExt.searchBehavior!.icon!);
}
}
if (searchProviders.isEmpty) {
return const Icon(Icons.search);
} else if (isBuiltInProvider) {
displayIcon = Icons.music_note;
}
return Padding(
@@ -2987,7 +3101,7 @@ class _SearchProviderDropdown extends ConsumerWidget {
const SizedBox(width: 12),
Expanded(
child: Text(
metadataSource == 'spotify' ? 'Spotify' : 'Deezer',
'Deezer',
style: TextStyle(
fontWeight:
currentProvider == null || currentProvider.isEmpty
@@ -3001,6 +3115,62 @@ class _SearchProviderDropdown extends ConsumerWidget {
],
),
),
// Built-in Tidal search option
PopupMenuItem<String>(
value: 'tidal',
child: Row(
children: [
Icon(
Icons.music_note,
size: 20,
color: currentProvider == 'tidal'
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 12),
Expanded(
child: Text(
'Tidal',
style: TextStyle(
fontWeight: currentProvider == 'tidal'
? FontWeight.w600
: FontWeight.normal,
),
),
),
if (currentProvider == 'tidal')
Icon(Icons.check, size: 18, color: colorScheme.primary),
],
),
),
// Built-in Qobuz search option
PopupMenuItem<String>(
value: 'qobuz',
child: Row(
children: [
Icon(
Icons.music_note,
size: 20,
color: currentProvider == 'qobuz'
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 12),
Expanded(
child: Text(
'Qobuz',
style: TextStyle(
fontWeight: currentProvider == 'qobuz'
? FontWeight.w600
: FontWeight.normal,
),
),
),
if (currentProvider == 'qobuz')
Icon(Icons.check, size: 18, color: colorScheme.primary),
],
),
),
if (searchProviders.isNotEmpty) const PopupMenuDivider(),
...searchProviders.map(
(ext) => PopupMenuItem<String>(
@@ -3829,6 +3999,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,
@@ -4136,7 +4307,7 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
artists: (data['artists'] ?? '').toString(),
releaseDate: (data['release_date'] ?? '').toString(),
totalTracks: data['total_tracks'] as int? ?? 0,
coverUrl: data['cover_url']?.toString(),
coverUrl: normalizeCoverReference(data['cover_url']?.toString()),
albumType: (data['album_type'] ?? 'album').toString(),
providerId: (data['provider_id'] ?? widget.extensionId).toString(),
);
@@ -4161,7 +4332,9 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
(data['artist_id'] ?? data['artistId'])?.toString() ??
widget.artistId,
albumId: data['album_id']?.toString(),
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
coverUrl: normalizeCoverReference(
(data['cover_url'] ?? data['images'])?.toString(),
),
isrc: data['isrc']?.toString(),
duration: (durationMs / 1000).round(),
trackNumber: data['track_number'] as int?,
+75 -9
View File
@@ -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) {
@@ -850,8 +850,8 @@ class _LibraryTracksFolderScreenState
final colorScheme = Theme.of(dialogContext).colorScheme;
return AlertDialog(
backgroundColor: colorScheme.surfaceContainerHigh,
title: const Text('Download All'),
content: Text('Download ${tracks.length} tracks?'),
title: Text(context.l10n.dialogDownloadAllTitle),
content: Text(context.l10n.dialogDownloadAllMessage(tracks.length)),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
@@ -862,7 +862,7 @@ class _LibraryTracksFolderScreenState
Navigator.pop(dialogContext);
_downloadAll(tracks);
},
child: const Text('Download'),
child: Text(context.l10n.dialogDownload),
),
],
);
@@ -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),
),
),
);
}
+324 -66
View File
@@ -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';
@@ -38,6 +42,13 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
final ScrollController _scrollController = ScrollController();
late List<LocalLibraryItem> _sortedTracksCache;
late Map<int, List<LocalLibraryItem>> _discGroupsCache;
void _showCueVirtualTrackSnackBar() {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text(cueVirtualTrackRequiresSplitMessage)),
);
}
late List<int> _sortedDiscNumbersCache;
late bool _hasMultipleDiscsCache;
String? _commonQualityCache;
@@ -178,9 +189,11 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
for (final id in idsToDelete) {
final item = tracksById[id];
if (item != null) {
try {
await deleteFile(item.filePath);
} catch (_) {}
if (!isCueVirtualPath(item.filePath)) {
try {
await deleteFile(item.filePath);
} catch (_) {}
}
await libraryNotifier.removeItem(id);
deletedCount++;
}
@@ -203,6 +216,10 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
}
Future<void> _openFile(LocalLibraryItem track) async {
if (isCueVirtualPath(track.filePath)) {
_showCueVirtualTrackSnackBar();
return;
}
try {
await ref
.read(playbackProvider.notifier)
@@ -233,7 +250,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
if (tracks.isEmpty) {
return Scaffold(
appBar: AppBar(title: Text(widget.albumName)),
body: const Center(child: Text('No tracks found for this album')),
body: Center(child: Text(context.l10n.noTracksFoundForAlbum)),
);
}
@@ -609,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),
),
);
}
@@ -801,6 +820,11 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
final format = item.format?.toLowerCase();
final lowerPath = item.filePath.toLowerCase();
final isMp3 = format == 'mp3' || lowerPath.endsWith('.mp3');
final isM4A =
format == 'm4a' ||
format == 'aac' ||
lowerPath.endsWith('.m4a') ||
lowerPath.endsWith('.aac');
final isOpus =
format == 'opus' ||
format == 'ogg' ||
@@ -814,6 +838,12 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
coverPath: effectiveCoverPath,
metadata: metadata,
);
} else if (isM4A) {
ffmpegResult = await FFmpegService.embedMetadataToM4a(
m4aPath: ffmpegTarget,
coverPath: effectiveCoverPath,
metadata: metadata,
);
} else if (isOpus) {
ffmpegResult = await FFmpegService.embedMetadataToOpus(
opusPath: ffmpegTarget,
@@ -883,6 +913,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>[];
@@ -991,8 +1143,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,
@@ -1004,7 +1205,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(
@@ -1041,51 +1241,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,
@@ -1138,6 +1360,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') {
@@ -1149,15 +1373,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) {
@@ -1169,16 +1399,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(
@@ -1225,12 +1461,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
try {
final result = await PlatformBridge.readFileMetadata(item.filePath);
if (result['error'] == null) {
result.forEach((key, value) {
if (key == 'error' || value == null) return;
final v = value.toString().trim();
if (v.isEmpty) return;
metadata[key.toUpperCase()] = v;
});
mergePlatformMetadataForTagEmbed(target: metadata, source: result);
}
} catch (_) {}
await ensureLyricsMetadataForConversion(
@@ -1343,13 +1574,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,
@@ -1420,6 +1665,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(
@@ -1511,6 +1757,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,
+59 -22
View File
@@ -33,7 +33,7 @@ class MainShell extends ConsumerStatefulWidget {
class _MainShellState extends ConsumerState<MainShell> {
int _currentIndex = 0;
late PageController _pageController;
late final PageController _pageController;
bool _hasCheckedUpdate = false;
StreamSubscription<String>? _shareSubscription;
DateTime? _lastBackPress;
@@ -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;
@@ -102,13 +101,30 @@ class _MainShellState extends ConsumerState<MainShell> {
if (_currentIndex != 0) {
_onNavTap(0);
}
ref.read(trackProvider.notifier).fetchFromUrl(url);
ref.read(settingsProvider.notifier).setHasSearchedBefore();
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(context.l10n.loadingSharedLink)));
}
await ref.read(trackProvider.notifier).fetchFromUrl(url);
final trackState = ref.read(trackProvider);
if (trackState.error != null && mounted) {
final l10n = context.l10n;
final errorMsg = trackState.error!;
final isRateLimit =
errorMsg.contains('429') ||
errorMsg.toLowerCase().contains('rate limit') ||
errorMsg.toLowerCase().contains('too many requests');
final displayMessage = errorMsg == 'url_not_recognized'
? l10n.errorUrlNotRecognizedMessage
: isRateLimit
? l10n.errorRateLimitedMessage
: l10n.errorUrlFetchFailed;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(displayMessage)));
}
}
Future<void> _checkForUpdates() async {
@@ -142,12 +158,10 @@ class _MainShellState extends ConsumerState<MainShell> {
if (settings.storageMode == 'saf') return;
if (settings.downloadDirectory.isEmpty) return;
// Check Android version
final deviceInfo = DeviceInfoPlugin();
final androidInfo = await deviceInfo.androidInfo;
if (androidInfo.version.sdkInt < 29) return;
// Only show once
final prefs = await SharedPreferences.getInstance();
if (prefs.getBool(_safMigrationShownKey) == true) return;
await prefs.setBool(_safMigrationShownKey, true);
@@ -165,25 +179,20 @@ class _MainShellState extends ConsumerState<MainShell> {
size: 32,
color: colorScheme.primary,
),
title: const Text('Storage Update Required'),
content: const Column(
title: Text(context.l10n.safMigrationTitle),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. '
'This fixes "permission denied" errors on Android 10+.',
),
SizedBox(height: 12),
Text(
'Please select your download folder again to switch to the new storage system.',
),
Text(context.l10n.safMigrationMessage1),
const SizedBox(height: 12),
Text(context.l10n.safMigrationMessage2),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Later'),
child: Text(context.l10n.updateLater),
),
FilledButton(
onPressed: () async {
@@ -203,15 +212,13 @@ class _MainShellState extends ConsumerState<MainShell> {
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Download folder updated to SAF mode'),
),
SnackBar(content: Text(context.l10n.safMigrationSuccess)),
);
}
}
}
},
child: const Text('Select Folder'),
child: Text(context.l10n.setupSelectFolder),
),
],
),
@@ -244,6 +251,7 @@ class _MainShellState extends ConsumerState<MainShell> {
}
if (_currentIndex != index) {
final shouldResetHome = index == 0;
HapticFeedback.selectionClick();
setState(() => _currentIndex = index);
final showStore = ref.read(
@@ -253,6 +261,10 @@ class _MainShellState extends ConsumerState<MainShell> {
currentTabIndex: _currentIndex,
showStoreTab: showStore,
);
FocusManager.instance.primaryFocus?.unfocus();
if (shouldResetHome) {
_resetHomeToMain();
}
_pageController.animateToPage(
index,
duration: const Duration(milliseconds: 250),
@@ -492,11 +504,15 @@ class _MainShellState extends ConsumerState<MainShell> {
return true;
},
child: Scaffold(
body: PageView(
body: PageView.builder(
controller: _pageController,
itemCount: tabs.length,
onPageChanged: _onPageChanged,
physics: const NeverScrollableScrollPhysics(),
children: tabs,
itemBuilder: (context, index) => _KeepAliveTabPage(
key: ValueKey('page-$index'),
child: tabs[index],
),
),
bottomNavigationBar: NavigationBar(
selectedIndex: _currentIndex.clamp(0, maxIndex),
@@ -557,6 +573,27 @@ class _LibraryTabRoot extends ConsumerWidget {
}
}
class _KeepAliveTabPage extends StatefulWidget {
final Widget child;
const _KeepAliveTabPage({super.key, required this.child});
@override
State<_KeepAliveTabPage> createState() => _KeepAliveTabPageState();
}
class _KeepAliveTabPageState extends State<_KeepAliveTabPage>
with AutomaticKeepAliveClientMixin<_KeepAliveTabPage> {
@override
bool get wantKeepAlive => true;
@override
Widget build(BuildContext context) {
super.build(context);
return widget.child;
}
}
class BouncingIcon extends StatefulWidget {
final Widget child;
const BouncingIcon({super.key, required this.child});
+127 -39
View File
@@ -8,6 +8,7 @@ import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/library_collections_provider.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/string_utils.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';
@@ -39,8 +40,12 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
List<Track>? _fetchedTracks;
bool _isLoading = false;
String? _error;
String? _resolvedPlaylistName;
String? _resolvedCoverUrl;
List<Track> get _tracks => _fetchedTracks ?? widget.tracks;
String get _playlistName => _resolvedPlaylistName ?? widget.playlistName;
String? get _coverUrl => _resolvedCoverUrl ?? widget.coverUrl;
@override
void initState() {
@@ -65,18 +70,25 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
});
try {
// Extract numeric ID from "deezer:123" format
String playlistId = widget.playlistId!;
late final Map<String, dynamic> result;
if (playlistId.startsWith('deezer:')) {
playlistId = playlistId.substring(7);
result = await PlatformBridge.getDeezerMetadata('playlist', playlistId);
} else if (playlistId.startsWith('qobuz:')) {
playlistId = playlistId.substring(6);
result = await PlatformBridge.getQobuzMetadata('playlist', playlistId);
} else if (playlistId.startsWith('tidal:')) {
playlistId = playlistId.substring(6);
result = await PlatformBridge.getTidalMetadata('playlist', playlistId);
} else {
result = await PlatformBridge.getDeezerMetadata('playlist', playlistId);
}
final result = await PlatformBridge.getDeezerMetadata(
'playlist',
playlistId,
);
if (!mounted) return;
final playlistInfo = result['playlist_info'] as Map<String, dynamic>?;
final owner = playlistInfo?['owner'] as Map<String, dynamic>?;
// Go backend returns 'track_list' not 'tracks'
final trackList = result['track_list'] as List<dynamic>? ?? [];
final tracks = trackList
@@ -85,6 +97,10 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
setState(() {
_fetchedTracks = tracks;
_resolvedPlaylistName = (playlistInfo?['name'] ?? owner?['name'])
?.toString();
_resolvedCoverUrl = (playlistInfo?['images'] ?? owner?['images'])
?.toString();
_isLoading = false;
});
} catch (e) {
@@ -113,7 +129,9 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
albumArtist: data['album_artist']?.toString(),
artistId: (data['artist_id'] ?? data['artistId'])?.toString(),
albumId: data['album_id']?.toString(),
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
coverUrl: normalizeCoverReference(
(data['cover_url'] ?? data['images'])?.toString(),
),
isrc: data['isrc']?.toString(),
duration: (durationMs / 1000).round(),
trackNumber: data['track_number'] as int?,
@@ -184,7 +202,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
duration: const Duration(milliseconds: 200),
opacity: _showTitleInAppBar ? 1.0 : 0.0,
child: Text(
widget.playlistName,
_playlistName,
style: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.w600,
@@ -206,10 +224,9 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
background: Stack(
fit: StackFit.expand,
children: [
if (widget.coverUrl != null)
if (_coverUrl != null)
CachedNetworkImage(
imageUrl:
_highResCoverUrl(widget.coverUrl) ?? widget.coverUrl!,
imageUrl: _highResCoverUrl(_coverUrl) ?? _coverUrl!,
fit: BoxFit.cover,
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) =>
@@ -256,7 +273,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.playlistName,
_playlistName,
style: const TextStyle(
color: Colors.white,
fontSize: 24,
@@ -336,7 +353,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());
}
@@ -416,7 +432,12 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
onSelect: (quality, service) {
ref
.read(downloadQueueProvider.notifier)
.addToQueue(track, service, qualityOverride: quality);
.addToQueue(
track,
service,
qualityOverride: quality,
playlistName: _playlistName,
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarAddedToQueue(track.name)),
@@ -427,7 +448,11 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
} else {
ref
.read(downloadQueueProvider.notifier)
.addToQueue(track, settings.defaultService);
.addToQueue(
track,
settings.defaultService,
playlistName: _playlistName,
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))),
);
@@ -482,7 +507,9 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
size: 22,
color: allLoved ? Colors.redAccent : Colors.white,
),
tooltip: allLoved ? 'Remove from Loved' : 'Love All',
tooltip: allLoved
? context.l10n.trackOptionRemoveFromLoved
: context.l10n.tooltipLoveAll,
padding: EdgeInsets.zero,
),
);
@@ -505,10 +532,15 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
Widget _buildAddToPlaylistButton(BuildContext context) {
return _buildCircleButton(
icon: Icons.playlist_add,
tooltip: 'Add to Playlist',
tooltip: context.l10n.tooltipAddToPlaylist,
onPressed: _tracks.isEmpty
? null
: () => showAddTracksToPlaylistSheet(context, ref, _tracks),
: () => showAddTracksToPlaylistSheet(
context,
ref,
_tracks,
playlistNamePrefill: widget.playlistName,
),
);
}
@@ -520,8 +552,8 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
final colorScheme = Theme.of(dialogContext).colorScheme;
return AlertDialog(
backgroundColor: colorScheme.surfaceContainerHigh,
title: const Text('Download All'),
content: Text('Download ${_tracks.length} tracks?'),
title: Text(context.l10n.dialogDownloadAllTitle),
content: Text(context.l10n.dialogDownloadAllMessage(_tracks.length)),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
@@ -532,7 +564,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
Navigator.pop(dialogContext);
_downloadAll(context);
},
child: const Text('Download'),
child: Text(context.l10n.dialogDownload),
),
],
);
@@ -552,7 +584,11 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Removed ${tracks.length} tracks from Loved')),
SnackBar(
content: Text(
context.l10n.snackbarRemovedTracksFromLoved(tracks.length),
),
),
);
}
} else {
@@ -565,7 +601,9 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Added $addedCount tracks to Loved')),
SnackBar(
content: Text(context.l10n.snackbarAddedTracksToLoved(addedCount)),
),
);
}
}
@@ -577,36 +615,86 @@ 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',
artistName: widget.playlistName,
trackName: '${tracksToQueue.length} tracks',
artistName: _playlistName,
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,
playlistName: _playlistName,
);
_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,
playlistName: _playlistName,
);
_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
+797 -384
View File
File diff suppressed because it is too large Load Diff
+4 -9
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/providers/track_provider.dart';
@@ -27,10 +28,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
_searchController = TextEditingController(text: widget.query);
if (widget.query.isNotEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
final settings = ref.read(settingsProvider);
ref
.read(trackProvider.notifier)
.search(widget.query, metadataSource: settings.metadataSource);
ref.read(trackProvider.notifier).search(widget.query);
});
}
}
@@ -44,10 +42,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
void _search() {
final query = _searchController.text.trim();
if (query.isNotEmpty) {
final settings = ref.read(settingsProvider);
ref
.read(trackProvider.notifier)
.search(query, metadataSource: settings.metadataSource);
ref.read(trackProvider.notifier).search(query);
}
}
@@ -58,7 +53,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
.addToQueue(track, settings.defaultService);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))));
}
@override
+26 -2
View File
@@ -234,7 +234,7 @@ class AboutPage extends StatelessWidget {
icon: Icons.info_outline,
title: context.l10n.aboutVersion,
subtitle:
'v${AppInfo.version} (build ${AppInfo.buildNumber})',
'v${AppInfo.displayVersion} (build ${AppInfo.buildNumber})',
showDivider: false,
),
],
@@ -341,7 +341,7 @@ class _AppHeaderCard extends StatelessWidget {
borderRadius: BorderRadius.circular(12),
),
child: Text(
'v${AppInfo.version}',
'v${AppInfo.displayVersion}',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: colorScheme.onSecondaryContainer,
fontWeight: FontWeight.w600,
@@ -511,6 +511,30 @@ class _TranslatorsSection extends StatelessWidget {
language: 'Japanese',
flag: '🇯🇵',
),
_Translator(
name: 'unkn0wn',
crowdinUsername: 'rdclvi',
language: 'Indonesian',
flag: '🇮🇩',
),
_Translator(
name: 'lunching1272',
crowdinUsername: 'lunching1272',
language: 'Chinese Simplified',
flag: '🇨🇳',
),
_Translator(
name: 'Сергей Ильченко',
crowdinUsername: 'Sega_Mostky',
language: 'Russian',
flag: '🇷🇺',
),
_Translator(
name: 'Girl-lass',
crowdinUsername: 'Girl-lass',
language: 'Chinese Simplified',
flag: '🇨🇳',
),
_Translator(
name: 'Kaan',
crowdinUsername: 'glai',
@@ -56,7 +56,7 @@ class _CacheManagementPageState extends ConsumerState<CacheManagementPage> {
setState(() => _isLoading = false);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Error: $e')));
).showSnackBar(SnackBar(content: Text(context.l10n.snackbarError(e.toString()))));
}
}
@@ -282,7 +282,7 @@ class _CacheManagementPageState extends ConsumerState<CacheManagementPage> {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Error: $e')));
).showSnackBar(SnackBar(content: Text(context.l10n.snackbarError(e.toString()))));
} finally {
if (mounted) {
setState(() => _busyAction = null);
@@ -394,7 +394,7 @@ class _CacheManagementPageState extends ConsumerState<CacheManagementPage> {
),
actions: [
IconButton(
tooltip: 'Refresh',
tooltip: context.l10n.cacheRefresh,
onPressed: _isBusy ? null : _refreshOverview,
icon: const Icon(Icons.refresh),
),
+157 -15
View File
@@ -164,7 +164,13 @@ class _RecentDonorsCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
const donorNames = <String>[];
const donorNames = <String>[
'McNuggets Jimmy',
'zcc09',
'micahRichie',
'a fan',
'CJBGR',
];
// Match SettingsGroup color logic
final cardColor = isDark
@@ -479,32 +485,78 @@ 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};
class _SupporterChip extends StatelessWidget {
// Diamond tier supporters ($50+ donors).
const _dv = <int>{560908930};
enum _SupporterTier { normal, gold, diamond }
_SupporterTier _tierOf(String name) {
final h = _cr(name);
if (_dv.contains(h)) return _SupporterTier.diamond;
if (_cv.contains(h)) return _SupporterTier.gold;
return _SupporterTier.normal;
}
class _SupporterChip extends StatefulWidget {
final String name;
final ColorScheme colorScheme;
const _SupporterChip({required this.name, required this.colorScheme});
@override
State<_SupporterChip> createState() => _SupporterChipState();
}
class _SupporterChipState extends State<_SupporterChip>
with SingleTickerProviderStateMixin {
late final _SupporterTier _tier;
AnimationController? _shimmerController;
@override
void initState() {
super.initState();
_tier = _tierOf(widget.name);
if (_tier == _SupporterTier.diamond) {
_shimmerController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 2400),
)..repeat();
}
}
@override
void dispose() {
_shimmerController?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final e = _cv.contains(_cr(name));
final isDark = Theme.of(context).brightness == Brightness.dark;
if (_tier == _SupporterTier.diamond) {
return _buildDiamondChip(isDark);
}
final isGold = _tier == _SupporterTier.gold;
const goldChipColor = Color(0xFFFFF8DC);
const goldAccentColor = Color(0xFFB8860B);
const goldDarkChipColor = Color(0xFF3A3000);
final chipColor = e ? goldChipColor : colorScheme.secondaryContainer;
final accentColor = e ? goldAccentColor : colorScheme.primary;
final isDark = Theme.of(context).brightness == Brightness.dark;
final effectiveChipColor = e && isDark ? goldDarkChipColor : chipColor;
final chipColor = isGold
? goldChipColor
: widget.colorScheme.secondaryContainer;
final accentColor = isGold ? goldAccentColor : widget.colorScheme.primary;
final effectiveChipColor = isGold && isDark ? goldDarkChipColor : chipColor;
return Material(
color: effectiveChipColor,
borderRadius: BorderRadius.circular(20),
child: Container(
decoration: e
decoration: isGold
? BoxDecoration(
borderRadius: BorderRadius.circular(20),
border: Border.all(
@@ -520,10 +572,12 @@ class _SupporterChip extends StatelessWidget {
CircleAvatar(
radius: 10,
backgroundColor: accentColor.withValues(alpha: 0.2),
child: e
child: isGold
? Icon(Icons.star_rounded, size: 12, color: accentColor)
: Text(
name.isNotEmpty ? name[0].toUpperCase() : '?',
widget.name.isNotEmpty
? widget.name[0].toUpperCase()
: '?',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
@@ -533,10 +587,12 @@ class _SupporterChip extends StatelessWidget {
),
const SizedBox(width: 8),
Text(
name,
widget.name,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: e ? accentColor : colorScheme.onSecondaryContainer,
fontWeight: e ? FontWeight.w600 : FontWeight.w500,
color: isGold
? accentColor
: widget.colorScheme.onSecondaryContainer,
fontWeight: isGold ? FontWeight.w600 : FontWeight.w500,
),
),
],
@@ -544,6 +600,92 @@ class _SupporterChip extends StatelessWidget {
),
);
}
Widget _buildDiamondChip(bool isDark) {
const diamondLight = Color(0xFFE8F4FD);
const diamondDark = Color(0xFF0D2B3E);
const diamondAccent = Color(0xFF4FC3F7);
const diamondHighlight = Color(0xFFB3E5FC);
final chipBg = isDark ? diamondDark : diamondLight;
return AnimatedBuilder(
animation: _shimmerController!,
builder: (context, child) {
final t = _shimmerController!.value;
return Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(20),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
gradient: LinearGradient(
begin: Alignment(-2.0 + 4.0 * t, 0.0),
end: Alignment(-1.0 + 4.0 * t, 0.0),
colors: [
chipBg,
isDark
? diamondAccent.withValues(alpha: 0.18)
: diamondHighlight.withValues(alpha: 0.7),
chipBg,
],
stops: const [0.0, 0.5, 1.0],
),
border: Border.all(
color: diamondAccent.withValues(
alpha: 0.5 + 0.3 * (0.5 - (t - 0.5).abs()),
),
width: 1.2,
),
boxShadow: [
BoxShadow(
color: diamondAccent.withValues(
alpha: 0.15 + 0.1 * (0.5 - (t - 0.5).abs()),
),
blurRadius: 8,
spreadRadius: 0,
),
],
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 20,
height: 20,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
diamondAccent.withValues(alpha: 0.3),
diamondAccent.withValues(alpha: 0.15),
],
),
),
child: const Icon(
Icons.diamond_rounded,
size: 12,
color: diamondAccent,
),
),
const SizedBox(width: 8),
Text(
widget.name,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: isDark ? diamondHighlight : diamondAccent,
fontWeight: FontWeight.w700,
),
),
],
),
),
);
},
);
}
}
class _NoticeLine extends StatelessWidget {

Some files were not shown because too many files have changed in this diff Show More