Compare commits

...

57 Commits

Author SHA1 Message Date
zarzet c66d13c9fd bump version to 3.8.6+112 2026-03-16 21:02:16 +07:00
github-actions[bot] 8529985a0e chore: update AltStore source to v3.8.6 2026-03-16 13:54:09 +00:00
zarzet a8a3973225 fix: prevent re-download of tracks converted to a different format
When a file is converted externally (e.g. FLAC to OPUS), the
orphan cleanup would delete the history entry because the original
path no longer exists. Now it checks for sibling files with other
audio extensions and updates the stored path instead of deleting.

Also add extension-stripped keys to path_match_keys so that
paths differing only by audio extension still match during local
library scan exclusion and queue deduplication.
2026-03-16 20:38:51 +07:00
zarzet 6710f90e1e feat: add auto-scan option for local library
Add a new 'Auto Scan' setting under Local Library with four modes:
off, every app open (10min cooldown), daily, and weekly. The app
uses WidgetsBindingObserver to trigger incremental scans on launch
and when resuming from background, respecting the configured
cooldown based on the last scan timestamp.
2026-03-16 20:35:59 +07:00
zarzet 929c5f3249 fix: remove double horizontal padding in store tab extension list
The extension list was wrapped in an extra Padding(horizontal: 16)
on top of SettingsGroup's default 16px margin, resulting in 32px
total inset. Remove the outer wrapper to match settings tab width.
2026-03-16 20:35:59 +07:00
zarzet f170ead7b9 docs: add contributors section to README
Add auto-generated contributor avatars via contrib.rocks with a
link to the GitHub contributors page. Include acknowledgement for
translators and bug reporters.
2026-03-16 20:35:59 +07:00
zarzet e63e366228 feat: add mc nuggets jimmy, CJBGR and michahRicie as supporters
Add new supporters to the donate page. michahRicie is highlighted
as a gold supporter.
2026-03-16 20:35:59 +07:00
zarzet 95e755e54e fix: delay iOS folder picker after sheet dismiss and update Afkar hosts 2026-03-16 20:35:59 +07:00
zarzet c719406425 docs: update readme 2026-03-16 20:35:59 +07:00
zarzet 9627ef66cf fix: verify resolved Tidal/Deezer tracks match the download request before downloading
SongLink can return incorrect track IDs (e.g. a different track from the
same album). Qobuz already had verification via qobuzTrackMatchesRequest.
This adds equivalent verification for Tidal and Deezer using a shared
trackMatchesRequest() helper in title_match_utils.go that checks artist,
title, and duration. Mismatched SongLink/ISRC results are now rejected
so the wrong audio is never embedded with Spotify metadata.
2026-03-16 20:35:59 +07:00
zarzet 15f977d98d fix: skip already-downloaded tracks in Download All for albums and playlists
Album and playlist Download All buttons now check download history and local
library before enqueuing, matching the existing behavior in artist discography
and CSV import. Tracks already in library are skipped with a summary snackbar.
2026-03-16 20:35:59 +07:00
zarzet 5b5f043624 docs: add extension store URL setup guide to README 2026-03-16 20:35:59 +07:00
zarzet 529a920b24 bump version to 3.8.5+111 2026-03-16 20:35:59 +07:00
zarzet 09eb6cf206 fix: use album-level artist for Various Artists albums instead of first track's artist
- Extension: fix extractSchemaOrg to find album-level schema (with numTracks) instead of per-track schema
- Extension: add secondaryText2 fallback in parseDescriptiveRows for VA album track artists
- Extension: use headerPrimaryText as primary album artist source, overriding schema.org
- App: album_screen now uses widget.artistName (album-level) instead of tracks.first.artistName
- App: home_tab _parseTrack now populates albumArtist from track data or album-level artist
- Bump Amazon extension to v2.0.1
2026-03-16 20:35:58 +07:00
zarzet af6fa6ea53 fix: extract cover art from M4A/ALAC files for conversion
Add extractCoverFromM4A() that reads the covr atom from the MP4
box tree (moov/udta/meta/ilst/covr/data). Wire it into
ExtractCoverToFile so ALAC-to-FLAC conversion preserves cover art.
2026-03-16 20:35:58 +07:00
zarzet 280b921755 fix: detect embedded lyrics in M4A/ALAC files
Add extractLyricsFromM4A() that walks the MP4 box tree
(moov/udta/meta/ilst/©lyr) to read lyrics. Wire it into
ExtractLyrics so the Embed Lyrics button is hidden when
lyrics already exist in the file.
2026-03-16 20:35:58 +07:00
zarzet 6ebe0c51ce fix: filter batch convert target formats based on source formats
Exclude same-format and lossy-to-lossless targets from the batch
convert sheet so users cannot pick pointless conversions like
FLAC→FLAC. Also clean up redundant inline comments.
2026-03-16 20:35:58 +07:00
zarzet 47bd24c1bd fix: preserve metadata and cover art in ALAC/M4A to FLAC conversion
- Use -map_metadata 0 instead of -map_metadata -1 so FFmpeg copies and
  auto-remaps source tags (M4A/ID3 → Vorbis comments) as a base
- Add _normalizeToVorbisComments() to filter technical fields (BIT_DEPTH,
  SAMPLE_RATE, DURATION) and normalize key variations to standard Vorbis
  comment names before applying overrides
- Switch cover art embedding from METADATA_BLOCK_PICTURE base64 (unreliable
  on Android due to command-line length limits) to -i cover -map 1:v
  -disposition attached_pic (same proven approach as embedMetadata and
  _convertToAlac)
- Drop zero-value track/disc numbers from override map to prevent
  clobbering source metadata with '0' from Go readFileMetadata
2026-03-16 20:35:58 +07:00
zarzet 2b23678c0d feat: add FLAC/ALAC bidirectional lossless conversion support
- Add _convertToAlac() and _convertToFlac() in ffmpeg_service with
  single-pass FFmpeg encoding, metadata tags, and cover art embedding
- Wire lossless formats (ALAC, FLAC) into single-track convert sheet
  with dynamic format list based on source format, hidden bitrate for
  lossless targets, and lossless hint text
- Add lossless conversion to batch convert UI in downloaded_album,
  local_album, and queue_tab screens with lossy-source filtering
- Fix M4A quality probe in Go backend: increase audio sample entry
  buffer from 24 to 32 bytes, read sample rate from correct offset
  (bytes 28-29) and bit depth from samplesize field (bytes 22-23)
- Add l10n keys for lossless confirm dialogs and hints (en, id)
2026-03-16 20:35:58 +07:00
zarzet e8327545ad feat: improve auto-fill track resolution in Edit Metadata sheet
- Identifier-first resolution (ISRC/Deezer/Spotify) before falling back to text search
- Score-based match selection via _metadataMatchScore instead of provider order
- Pass sourceTrackId from TrackMetadataScreen into _EditMetadataSheet
- Refactor buildDeezerExtendedMetadataResult and buildDeezerISRCSearchResult as testable helpers
- Add unit tests for buildDeezerExtendedMetadataResult and buildDeezerISRCSearchResult
- Propagate copyright through Deezer enrichment chain (exports, extension_providers)
2026-03-16 20:35:58 +07:00
zarzet 89a38af538 fix: resolve all flutter analyze warnings and improve auto-fill enrichment chain
- Fix use_build_context_synchronously in _embedLyrics by capturing l10n
  strings before async gaps (snackbarFailedToWriteStorage,
  snackbarFailedToEmbedLyrics, snackbarUnsupportedAudioFormat)
- Improve auto-fill metadata enrichment to use proper API chain:
  search providers -> convertSpotifyToDeezer (SongLink) for Deezer ID
  -> getDeezerMetadata for ISRC -> getDeezerExtendedMetadata for
  genre/label/copyright. Falls back to ISRC-based Deezer lookup when
  SongLink conversion unavailable.
- flutter analyze now reports 0 issues
2026-03-16 20:35:58 +07:00
zarzet b7f34ec47c feat: selective auto-fill from online in Edit Metadata sheet
Add 'Auto-fill from online' expandable section to the metadata editor
that lets users choose exactly which fields to populate from online
metadata search. Users can select individual fields via filter chips,
use 'All' or 'Empty only' quick-select buttons, then tap 'Fetch & Fill'
to search metadata providers and fill only the selected controllers.

The search uses existing searchTracksWithMetadataProviders API with
ISRC-preferring best-match selection. Extended metadata (genre, label,
copyright) is fetched via Deezer extended metadata API when available.
Cover art is downloaded from the match's cover_url. All results are
previewed in the editor before saving — nothing is written to the file
until the user taps Save.

Add 21 new l10n keys (editMetadata* namespace) for all UI strings.
2026-03-16 20:35:58 +07:00
zarzet 967523bfc6 feat: queue FLAC redownloads for local library tracks
Add LocalTrackRedownloadService with confidence-scored metadata matching
(ISRC, title, artist, album, duration, track/disc number, year) to find
reliable online matches for locally-stored tracks.

Wire up 'Queue FLAC' selection action in both local_album_screen and
queue_tab (library tab). Shows progress snackbar during resolution,
skips ambiguous or low-confidence matches, and reports results.

Add Indonesian (id) translations for all queueFlac l10n keys.
2026-03-16 20:35:58 +07:00
zarzet 29d8a185f9 fix: handle nested legacy iOS Documents path in validation
Detect and recover from stale sandbox container paths embedded inside
the current Documents directory. Extracts helper functions for path
suffix normalization and joining to reduce duplication.
2026-03-16 20:35:57 +07:00
zarzet 4495d4bf4e feat: add Opus 320kbps quality, remove Tidal HIGH tier
- Add YouTubeQualityOpus320 constant and opus_320 parser case in Go backend
- Expand opus supported bitrates to [128, 256, 320] across Go, Dart settings, and UI
- Update default YouTube Opus option from 256 to 320kbps
- Remove Tidal HIGH (lossy 320kbps) quality from Go backend, settings model,
  settings provider, download queue provider (both SAF and non-SAF paths),
  settings UI (quality option, format picker, helper methods), and l10n keys
- Add settings migration v6: auto-migrate users with audioQuality=HIGH to LOSSLESS
- Update and add Go test cases for opus_320 and adjusted max bitrate
- Regenerate l10n files, remove 10 unused downloadLossy* l10n keys
2026-03-16 20:35:57 +07:00
zarzet 67737467e0 ci: auto-update AltStore source (apps.json) on release 2026-03-16 20:35:57 +07:00
renovate[bot] 13845eea04 chore(deps): update dependency flutter to v3.41.4 2026-03-16 20:35:57 +07:00
zarzet 12779778d3 fix(i18n): localize hardcoded strings in bulk playlist download and fix trailing newlines 2026-03-16 20:35:57 +07:00
ViscousPot d4178ad036 feat: add option to download multiple selected playlists 2026-03-16 20:35:57 +07:00
ViscousPot 49ea84384d feat: auto fill playlist name during import 2026-03-16 20:35:57 +07:00
ViscousPot a6d9849468 Update CONTRIBUTING.md 2026-03-16 20:35:57 +07:00
ViscousPot 16100aa0fd add fvm 2026-03-16 20:35:57 +07:00
zarzet 387dd47374 feat: add Qobuz Afkar API provider and prefer request metadata for consistent album grouping 2026-03-16 20:35:57 +07:00
github-actions[bot] f67f52eba9 chore: update AltStore source to v3.8.5 2026-03-15 21:35:25 +00:00
zarzet 18607597e9 fix: correct AltStore icon URL to assets/images/logo.png 2026-03-15 19:41:25 +07:00
Zarz Eleutherius 78cd396847 Merge pull request #233 from Amonoman/main
Add AltStore source and update README documentation
2026-03-15 19:07:50 +07:00
Amonoman 8540da484f Add AltStore source and update README 2026-03-15 13:02:23 +01:00
zarzet 8c18c7b8f1 Merge branch 'main' of https://github.com/zarzet/SpotiFLAC-Mobile 2026-03-14 23:12:26 +07:00
zarzet 10c5293f64 chore: update VirusTotal hash for v3.8.0 2026-03-14 23:10:19 +07:00
zarzet d5381afcf9 chore: bump app version to 3.8.0+106 2026-03-14 21:49:22 +07:00
zarzet 134bf4375f feat: auto-enrich metadata for extension downloads, fix artist/playlist parsing, and improve metadata screen
- Add metadata provider search (Deezer/Tidal/Qobuz) in download pipeline for extension tracks with missing album/date/ISRC, using the same mechanism as ReEnrichFile
- Always pass enriched metadata (album, release_date, ISRC, cover_url, track/disc number) back in DownloadResponse so Flutter can embed them
- Add Deezer ISRC lookup for genre/label during download enrichment
- Extend _buildTrackForMetadataEmbedding to use ISRC, cover_url, album_artist from backend response
- Add Releases section support in artist page (Go + Flutter)
- Fix Track ID parsing to prefer non-empty native ID over empty spotify_id
- Paginate popular tracks (5 per page with swipe + dot indicators)
- Fix metadata screen: duration getter checks _editedMetadata, read album/duration from file tags
- Make metadata screen ID labels and Open-in buttons source-aware (Amazon/Tidal/Qobuz/Deezer/Spotify)
- Copy enrichment fields (AlbumName, DurationMS, CoverURL, AlbumArtist, ID) back to download request
- Update README badge, add network_requests.txt to gitignore
2026-03-14 21:47:57 +07:00
zarzet aa9854fc0a perf: optimize polling, progress caching, staggered warmup, and snapshot-based library scan
- Reduce polling interval from 800ms to 1200ms across download progress, library scan, and Android native stream
- Add dirty-flag caching to Go GetMultiProgress() to skip redundant JSON marshaling
- Replace eager provider initialization with staggered Timer-based warmup (400/900/1600ms)
- Add snapshot-based incremental library scan to avoid large MethodChannel payloads
- Move history stats and grouped album filtering to Riverpod providers for better cache invalidation
- Cap home tab history preview to 48 items with deep equality wrapper to reduce rebuilds
- Throttle foreground service notification updates to 2% progress buckets
- Migrate PageView to PageView.builder with AutomaticKeepAliveClientMixin
- Add comparison table to README
2026-03-14 16:52:33 +07:00
zarzet 10bc29e347 feat: add Qobuz and Tidal as built-in metadata search providers with priority-based unified search 2026-03-14 16:07:41 +07:00
zarzet 733efce161 fix: fix Tidal track resolution, playlist owner info, and improve track provider state 2026-03-14 15:42:21 +07:00
zarzet ac9141f167 feat: add Qobuz and Tidal metadata API, URL parsers, and full store support 2026-03-14 15:09:48 +07:00
zarzet d89850e8a9 feat: add name and images fields to PlaylistInfoMetadata 2026-03-14 15:07:34 +07:00
zarzet 5948e4f125 chore: remove redundant inline comments 2026-03-14 15:07:15 +07:00
zarzet 34d22f783c feat: add store registry URL management, port iOS handlers, and clean up store UI
Add set/get/clear store registry URL method channel handlers on Android,
iOS, and Go backend so users can configure a custom extension repository.

Store tab now shows a setup screen when no registry URL is configured,
with a cleaner layout (removed redundant description and helper text)
and visible TextField borders for dark theme.

Minor comment and formatting cleanups across several files.
2026-03-14 13:24:30 +07:00
Zarz Eleutherius c347b6999e Merge pull request #218 from ViscousPot/fix/folder-organization-by-playlist-for-library-playlists
Reviewed and approved. The fix correctly passes playlistName to addMultipleToQueue() for library playlists, consistent with playlist_screen.dart pattern.
2026-03-14 12:57:32 +07:00
ViscousPot adc74741ce Update library_tracks_folder_screen.dart 2026-03-14 01:48:34 +00:00
zarzet 48f614359e feat(i18n): replace all hardcoded strings with l10n keys across 13 screens
- Added 80+ new keys to app_en.arb covering lyrics, SAF, download settings,
  snackbars, dialogs, home, cache, and store screens
- Replaced hardcoded strings in main_shell, album_screen, playlist_screen,
  library_tracks_folder_screen, home_tab, settings_tab, download_settings_page,
  lyrics_provider_priority_page, track_metadata_screen, extension_detail_page,
  cache_management_page, local_album_screen, downloaded_album_screen, search_screen
- Fixed structural bug in track_metadata_screen (duplicate closing brace)
- Added missing l10n.dart import to search_screen.dart
- Regenerated all app_localizations*.dart files via flutter gen-l10n
2026-03-13 15:12:12 +07:00
zarzet 16669d8b7a feat: show 'Internal' version in debug builds, optimize download timeouts, and fix navigation safety
- Add displayVersion getter using kDebugMode: debug shows 'Internal', release shows actual version
- Defer Spotify URL resolution in Deezer downloader until fallback is actually needed
- Unify download timeouts to 24h constant (connection-level timeouts still protect hung connections)
- Fix context shadowing in track metadata options menu and delete dialog
- Use addPostFrameCallback + mounted guards for safer sheet/dialog navigation
2026-03-12 04:02:14 +07:00
zarzet f1eef47600 refactor: optimize SAF metadata reading, CUE sibling resolution, and startup initialization
- Add fast-path SAF metadata reading via /proc/self/fd with displayNameHint support, falling back to temp copy
- Replace repeated findFile() CUE audio sibling lookups with cached case-insensitive directory listing
- Cache parsed CUE sheets to avoid redundant parsing during library scans
- Optimize incremental scan CUE modTime lookup from O(N*M) to O(N+M)
- Defer local library provider loading until localLibraryEnabled setting is true
- Replace O(n) track+artist history lookup with O(1) map-based lookup
- Delay startup maintenance tasks by 2s to reduce launch-time contention
2026-03-12 03:36:48 +07:00
zarzet fc1567d2c8 Merge branch 'main' into dev 2026-03-12 02:52:32 +07:00
zarzet fffce6039a feat: add Deezer entry in provider priority UI and improve release changelog
- Add 'deezer' case with icon to _ProviderItem in provider_priority_page.dart
- Fix release.yml: deterministic previous-tag lookup for Full Changelog link
- Strip version header line and author attribution from Telegram changelog
- cliff.toml: hide repo owner username from commit attribution
- cliff.toml: remove PR number stripping preprocessor
2026-03-12 02:51:37 +07:00
zarzet df77ae3986 fix(ios): remove stale built-in Spotify bridge handlers 2026-03-11 17:16:40 +07:00
zarzet 3cd6d068a2 docs: add centered Trendshift badge below README banner 2026-03-11 17:16:39 +07:00
98 changed files with 17117 additions and 2211 deletions
+3
View File
@@ -0,0 +1,3 @@
{
"flutter": "3.41.4"
}
+71 -2
View File
@@ -344,9 +344,18 @@ jobs:
VERSION=${{ needs.get-version.outputs.version }}
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
cp /tmp/changelog.txt /tmp/release_body.txt
# 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
@@ -384,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]
@@ -424,7 +490,10 @@ jobs:
else
# 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' | \
+4
View File
@@ -67,6 +67,7 @@ AGENTS.md
# Temp/misc
nul
network_requests.txt
# Log files
*.log
@@ -76,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
```
+35 -8
View File
@@ -6,6 +6,12 @@
<img alt="SpotiFLAC Mobile" src="assets/images/banner-readme-light.png" width="650" height="auto">
</picture>
<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>
## Screenshots
@@ -19,8 +25,8 @@
<div align="center">
[![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)
[![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)
@@ -34,13 +40,14 @@ Extensions allow the community to add new music sources and features without wai
### Installing Extensions
1. Go to **Store** tab in the app
2. Browse and install extensions with one tap
3. Or download a `.spotiflac-ext` file and install manually via **Settings > Extensions**
4. Configure extension settings if needed
5. Set provider priority in **Settings > Extensions > Provider Priority**
2. When opening the Store for the first time, you will be asked to enter an **Extension Repository URL**
3. Browse and install extensions with one tap
4. Or download a `.spotiflac-ext` file and install manually via **Settings > Extensions**
5. Configure extension settings if needed
6. Set provider priority in **Settings > Extensions > Provider Priority**
### Developing Extensions
Want to create your own extension? Check out the [Extension Development Guide](https://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
@@ -49,6 +56,9 @@ Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music for Window
## FAQ
**Q: Why does the Store tab ask me to enter a URL?**
A: Starting from version 3.8.0, SpotiFLAC uses a decentralized extension repository system — extensions are hosted on GitHub repositories rather than a built-in server, so anyone can create and host their own. Enter a repository URL in the Store tab to browse and install extensions.
**Q: Why is my download failing with "Song not found"?**
A: The track may not be available on the streaming services. Try enabling more download services in Settings > Download > Provider Priority, or install additional extensions like Amazon Music from the Store.
@@ -67,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?
@@ -74,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)
@@ -81,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()
}
@@ -38,7 +38,7 @@ class MainActivity: FlutterFragmentActivity() {
"com.zarz.spotiflac/download_progress_stream"
private val LIBRARY_SCAN_PROGRESS_STREAM_CHANNEL =
"com.zarz.spotiflac/library_scan_progress_stream"
private val STREAM_POLLING_INTERVAL_MS = 800L
private val STREAM_POLLING_INTERVAL_MS = 1200L
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var pendingSafTreeResult: MethodChannel.Result? = null
private val safScanLock = Any()
@@ -469,6 +469,32 @@ class MainActivity: FlutterFragmentActivity() {
lastLibraryScanProgressPayload = null
}
private fun loadExistingFilesJsonFromSnapshot(snapshotPath: String): String {
if (snapshotPath.isBlank()) {
return "{}"
}
val snapshotFile = File(snapshotPath)
if (!snapshotFile.exists()) {
return "{}"
}
val result = JSONObject()
snapshotFile.forEachLine { line ->
if (line.isBlank()) return@forEachLine
val separatorIndex = line.indexOf('\t')
if (separatorIndex <= 0 || separatorIndex >= line.length - 1) {
return@forEachLine
}
val modTime = line.substring(0, separatorIndex).toLongOrNull() ?: 0L
val filePath = line.substring(separatorIndex + 1)
if (filePath.isNotEmpty()) {
result.put(filePath, modTime)
}
}
return result.toString()
}
private fun resolveSafFile(treeUriStr: String, relativeDir: String, fileName: String): String {
val obj = JSONObject()
if (treeUriStr.isBlank() || fileName.isBlank()) {
@@ -703,6 +729,80 @@ class MainActivity: FlutterFragmentActivity() {
}
}
private fun buildUriDisplayName(
uri: Uri,
displayNameHint: String? = null,
fallbackExt: String? = null,
): String {
val explicitName = displayNameHint?.trim().orEmpty()
if (explicitName.isNotEmpty()) return explicitName
val docName = try { DocumentFile.fromSingleUri(this, uri)?.name } catch (_: Exception) { null }
val uriName = uri.lastPathSegment
val resolvedName = (docName ?: uriName ?: "").trim()
if (resolvedName.isNotEmpty()) return resolvedName
val ext = when {
fallbackExt.isNullOrBlank().not() -> fallbackExt
isMediaStoreUri(uri) -> resolveMediaStoreExt(uri, fallbackExt)
else -> ""
}
return if (ext.isNullOrBlank()) "audio" else "audio$ext"
}
private fun readAudioMetadataFromUri(
uri: Uri,
displayNameHint: String? = null,
fallbackExt: String? = null,
): JSONObject? {
val displayName = buildUriDisplayName(uri, displayNameHint, fallbackExt)
try {
contentResolver.openFileDescriptor(uri, "r")?.use { pfd ->
val directPath = "/proc/self/fd/${pfd.fd}"
val metadataJson = Gobackend.readAudioMetadataWithHintJSON(directPath, displayName)
if (metadataJson.isNotBlank()) {
val obj = JSONObject(metadataJson)
if (!obj.has("error")) {
return obj
}
}
}
} catch (e: Exception) {
android.util.Log.d(
"SpotiFLAC",
"Direct SAF metadata read fallback for $uri: ${e.message}",
)
}
val tempPath = try {
copyUriToTemp(uri, fallbackExt)
} catch (e: Exception) {
android.util.Log.w(
"SpotiFLAC",
"SAF metadata fallback copy failed for $uri: ${e.message}",
)
null
} ?: return null
try {
val metadataJson = Gobackend.readAudioMetadataWithHintJSON(tempPath, displayName)
if (metadataJson.isBlank()) return null
val obj = JSONObject(metadataJson)
return if (obj.has("error")) null else obj
} catch (e: Exception) {
android.util.Log.w(
"SpotiFLAC",
"SAF metadata temp read failed for $uri: ${e.message}",
)
return null
} finally {
try {
File(tempPath).delete()
} catch (_: Exception) {}
}
}
private fun writeUriFromPath(uri: Uri, srcPath: String): Boolean {
val srcFile = File(srcPath)
if (!srcFile.exists()) return false
@@ -873,6 +973,66 @@ class MainActivity: FlutterFragmentActivity() {
return null
}
private val cueSiblingAudioExtensions = listOf(
".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a"
)
private fun getSafChildFileLookup(
dir: DocumentFile,
cache: MutableMap<String, Map<String, DocumentFile>>,
): Map<String, DocumentFile> {
val dirKey = dir.uri.toString()
return cache.getOrPut(dirKey) {
try {
buildMap {
for (child in dir.listFiles()) {
if (!child.isFile) continue
val childName = child.name?.trim().orEmpty()
if (childName.isBlank()) continue
put(childName.lowercase(Locale.ROOT), child)
}
}
} catch (e: Exception) {
android.util.Log.w(
"SpotiFLAC",
"Failed to build SAF child lookup for $dirKey: ${e.message}",
)
emptyMap()
}
}
}
private fun resolveCueAudioSibling(
parentDir: DocumentFile,
cueName: String,
audioFileName: String?,
childLookupCache: MutableMap<String, Map<String, DocumentFile>>,
): DocumentFile? {
val childLookup = getSafChildFileLookup(parentDir, childLookupCache)
val directMatch = audioFileName
?.trim()
?.takeIf { it.isNotEmpty() }
?.substringAfterLast("/")
?.substringAfterLast("\\")
?.lowercase(Locale.ROOT)
?.let(childLookup::get)
if (directMatch != null) {
return directMatch
}
val cueBaseName = cueName.substringBeforeLast('.').trim()
if (cueBaseName.isBlank()) {
return null
}
val cueBaseKey = cueBaseName.lowercase(Locale.ROOT)
for (ext in cueSiblingAudioExtensions) {
childLookup["$cueBaseKey$ext"]?.let { return it }
}
return null
}
private fun scanSafTree(treeUriStr: String): String {
if (treeUriStr.isBlank()) return "[]"
@@ -891,6 +1051,7 @@ class MainActivity: FlutterFragmentActivity() {
// CUE files: (cueDoc, parentDir) — we need the parent to find sibling audio
val cueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
val visitedDirUris = mutableSetOf<String>()
val safChildLookupCache = mutableMapOf<String, Map<String, DocumentFile>>()
var traversalErrors = 0
val queue: ArrayDeque<Pair<DocumentFile, String>> = ArrayDeque()
@@ -987,7 +1148,6 @@ class MainActivity: FlutterFragmentActivity() {
var tempCuePath: String? = null
var tempAudioPath: String? = null
try {
// Copy CUE to temp
tempCuePath = copyUriToTemp(cueDoc.uri, ".cue")
if (tempCuePath == null) {
errors++
@@ -996,27 +1156,14 @@ class MainActivity: FlutterFragmentActivity() {
continue
}
// Extract the audio filename from the CUE sheet text
val audioFileName = extractCueAudioFileName(tempCuePath)
// Find the referenced audio file as a sibling in the same SAF directory
var audioDoc: DocumentFile? = null
if (!audioFileName.isNullOrBlank()) {
audioDoc = try { parentDir.findFile(audioFileName) } catch (_: Exception) { null }
}
// Fallback: try common audio extensions with the CUE base name
if (audioDoc == null) {
val cueBaseName = cueName.substringBeforeLast('.')
val commonExts = listOf(".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a")
for (ext in commonExts) {
audioDoc = try { parentDir.findFile(cueBaseName + ext) } catch (_: Exception) { null }
if (audioDoc != null) break
// Try uppercase
audioDoc = try { parentDir.findFile(cueBaseName + ext.uppercase(Locale.ROOT)) } catch (_: Exception) { null }
if (audioDoc != null) break
}
}
val audioDoc = resolveCueAudioSibling(
parentDir = parentDir,
cueName = cueName,
audioFileName = audioFileName,
childLookupCache = safChildLookupCache,
)
if (audioDoc == null) {
android.util.Log.w("SpotiFLAC", "SAF scan: no audio file found for CUE $cueName")
@@ -1052,7 +1199,6 @@ class MainActivity: FlutterFragmentActivity() {
val cueLastModified = try { cueDoc.lastModified() } catch (_: Exception) { 0L }
// Call Go to produce library scan entries for each CUE track
val cueResultsJson = Gobackend.scanCueSheetForLibrary(
tempCuePath,
tempDir,
@@ -1111,35 +1257,17 @@ class MainActivity: FlutterFragmentActivity() {
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
val fallbackExt = if (ext.isNotBlank()) ".${ext}" else null
val tempPath = try {
copyUriToTemp(doc.uri, fallbackExt)
} catch (e: Exception) {
android.util.Log.w(
"SpotiFLAC",
"SAF scan: failed to copy ${doc.uri}: ${e.message}",
)
null
}
if (tempPath == null) {
val metadataObj = readAudioMetadataFromUri(doc.uri, name, fallbackExt)
if (metadataObj == null) {
errors++
} else {
try {
val metadataJson = Gobackend.readAudioMetadataJSON(tempPath)
if (metadataJson.isNotBlank()) {
val obj = JSONObject(metadataJson)
val lastModified = try { doc.lastModified() } catch (_: Exception) { 0L }
obj.put("filePath", doc.uri.toString())
obj.put("fileModTime", lastModified)
results.put(obj)
} else {
errors++
}
val lastModified = try { doc.lastModified() } catch (_: Exception) { 0L }
metadataObj.put("filePath", doc.uri.toString())
metadataObj.put("fileModTime", lastModified)
results.put(metadataObj)
} catch (_: Exception) {
errors++
} finally {
try {
File(tempPath).delete()
} catch (_: Exception) {}
}
}
@@ -1214,6 +1342,7 @@ class MainActivity: FlutterFragmentActivity() {
val unchangedCueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
val currentUris = mutableSetOf<String>()
val visitedDirUris = mutableSetOf<String>()
val safChildLookupCache = mutableMapOf<String, Map<String, DocumentFile>>()
var traversalErrors = 0
// Build a map of CUE base URIs -> existing virtual track URIs from the database.
@@ -1398,22 +1527,12 @@ class MainActivity: FlutterFragmentActivity() {
val audioFileName = extractCueAudioFileName(tempCuePath)
// Find the referenced audio file as a sibling in the same SAF directory
var audioDoc: DocumentFile? = null
if (!audioFileName.isNullOrBlank()) {
audioDoc = try { parentDir.findFile(audioFileName) } catch (_: Exception) { null }
}
// Fallback: try common audio extensions with the CUE base name
if (audioDoc == null) {
val cueBaseName = cueName.substringBeforeLast('.')
val commonExts = listOf(".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a")
for (ext in commonExts) {
audioDoc = try { parentDir.findFile(cueBaseName + ext) } catch (_: Exception) { null }
if (audioDoc != null) break
audioDoc = try { parentDir.findFile(cueBaseName + ext.uppercase(Locale.ROOT)) } catch (_: Exception) { null }
if (audioDoc != null) break
}
}
val audioDoc = resolveCueAudioSibling(
parentDir = parentDir,
cueName = cueName,
audioFileName = audioFileName,
childLookupCache = safChildLookupCache,
)
if (audioDoc == null) {
android.util.Log.w("SpotiFLAC", "SAF incremental scan: no audio file found for CUE $cueName")
@@ -1501,24 +1620,13 @@ class MainActivity: FlutterFragmentActivity() {
tempCue = copyUriToTemp(cueDoc.uri, ".cue")
if (tempCue != null) {
val audioFileName = extractCueAudioFileName(tempCue)
var audioDoc: DocumentFile? = null
if (!audioFileName.isNullOrBlank()) {
audioDoc = try { parentDir.findFile(audioFileName) } catch (_: Exception) { null }
}
// Fallback: try common extensions with CUE base name
if (audioDoc == null) {
val cueName = try { cueDoc.name ?: "" } catch (_: Exception) { "" }
val cueBaseName = cueName.substringBeforeLast('.')
if (cueBaseName.isNotBlank()) {
val commonExts = listOf(".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a")
for (ext in commonExts) {
audioDoc = try { parentDir.findFile(cueBaseName + ext) } catch (_: Exception) { null }
if (audioDoc != null) break
audioDoc = try { parentDir.findFile(cueBaseName + ext.uppercase(Locale.ROOT)) } catch (_: Exception) { null }
if (audioDoc != null) break
}
}
}
val cueName = try { cueDoc.name ?: "" } catch (_: Exception) { "" }
val audioDoc = resolveCueAudioSibling(
parentDir = parentDir,
cueName = cueName,
audioFileName = audioFileName,
childLookupCache = safChildLookupCache,
)
if (audioDoc != null) {
cueReferencedAudioUris.add(audioDoc.uri.toString())
}
@@ -1566,36 +1674,18 @@ class MainActivity: FlutterFragmentActivity() {
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
val fallbackExt = if (ext.isNotBlank()) ".${ext}" else null
val tempPath = try {
copyUriToTemp(doc.uri, fallbackExt)
} catch (e: Exception) {
android.util.Log.w(
"SpotiFLAC",
"SAF incremental scan: failed to copy ${doc.uri}: ${e.message}",
)
null
}
if (tempPath == null) {
val metadataObj = readAudioMetadataFromUri(doc.uri, name, fallbackExt)
if (metadataObj == null) {
errors++
} else {
try {
val metadataJson = Gobackend.readAudioMetadataJSON(tempPath)
if (metadataJson.isNotBlank()) {
val obj = JSONObject(metadataJson)
val safeLastModified = try { doc.lastModified() } catch (_: Exception) { lastModified }
obj.put("filePath", doc.uri.toString())
obj.put("fileModTime", safeLastModified)
obj.put("lastModified", safeLastModified)
results.put(obj)
} else {
errors++
}
val safeLastModified = try { doc.lastModified() } catch (_: Exception) { lastModified }
metadataObj.put("filePath", doc.uri.toString())
metadataObj.put("fileModTime", safeLastModified)
metadataObj.put("lastModified", safeLastModified)
results.put(metadataObj)
} catch (_: Exception) {
errors++
} finally {
try {
File(tempPath).delete()
} catch (_: Exception) {}
}
}
@@ -2556,6 +2646,22 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
"getQobuzMetadata" -> {
val resourceType = call.argument<String>("resource_type") ?: ""
val resourceId = call.argument<String>("resource_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getQobuzMetadata(resourceType, resourceId)
}
result.success(response)
}
"getTidalMetadata" -> {
val resourceType = call.argument<String>("resource_type") ?: ""
val resourceId = call.argument<String>("resource_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getTidalMetadata(resourceType, resourceId)
}
result.success(response)
}
"parseDeezerUrl" -> {
val url = call.argument<String>("url") ?: ""
val response = withContext(Dispatchers.IO) {
@@ -2563,6 +2669,13 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
"parseQobuzUrl" -> {
val url = call.argument<String>("url") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.parseQobuzURLExport(url)
}
result.success(response)
}
"parseTidalUrl" -> {
val url = call.argument<String>("url") ?: ""
val response = withContext(Dispatchers.IO) {
@@ -2791,6 +2904,15 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
"searchTracksWithMetadataProviders" -> {
val query = call.argument<String>("query") ?: ""
val limit = call.argument<Int>("limit") ?: 20
val includeExtensions = call.argument<Boolean>("include_extensions") ?: true
val response = withContext(Dispatchers.IO) {
Gobackend.searchTracksWithMetadataProvidersJSON(query, limit.toLong(), includeExtensions)
}
result.success(response)
}
"enrichTrackWithExtension" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val trackJson = call.argument<String>("track") ?: "{}"
@@ -2996,6 +3118,25 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(null)
}
"setStoreRegistryUrl" -> {
val registryUrl = call.argument<String>("registry_url") ?: ""
withContext(Dispatchers.IO) {
Gobackend.setStoreRegistryURLJSON(registryUrl)
}
result.success(null)
}
"getStoreRegistryUrl" -> {
val response = withContext(Dispatchers.IO) {
Gobackend.getStoreRegistryURLJSON()
}
result.success(response)
}
"clearStoreRegistryUrl" -> {
withContext(Dispatchers.IO) {
Gobackend.clearStoreRegistryURLJSON()
}
result.success(null)
}
"getStoreExtensions" -> {
val forceRefresh = call.argument<Boolean>("force_refresh") ?: false
val response = withContext(Dispatchers.IO) {
@@ -3071,6 +3212,18 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
"scanLibraryFolderIncrementalFromSnapshot" -> {
val folderPath = call.argument<String>("folder_path") ?: ""
val snapshotPath = call.argument<String>("snapshot_path") ?: ""
val response = withContext(Dispatchers.IO) {
safScanActive = false
Gobackend.scanLibraryFolderIncrementalFromSnapshotJSON(
folderPath,
snapshotPath,
)
}
result.success(response)
}
"scanSafTree" -> {
val treeUri = call.argument<String>("tree_uri") ?: ""
val response = withContext(Dispatchers.IO) {
@@ -3086,6 +3239,16 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
"scanSafTreeIncrementalFromSnapshot" -> {
val treeUri = call.argument<String>("tree_uri") ?: ""
val snapshotPath = call.argument<String>("snapshot_path") ?: ""
val response = withContext(Dispatchers.IO) {
val existingFilesJson =
loadExistingFilesJsonFromSnapshot(snapshotPath)
scanSafTreeIncremental(treeUri, existingFilesJson)
}
result.success(response)
}
"getSafFileModTimes" -> {
val uris = call.argument<String>("uris") ?: "[]"
val response = withContext(Dispatchers.IO) {
@@ -3116,13 +3279,10 @@ class MainActivity: FlutterFragmentActivity() {
try {
if (filePath.startsWith("content://")) {
val uri = Uri.parse(filePath)
val tempPath = copyUriToTemp(uri)
?: return@withContext """{"error":"Failed to copy SAF file to temp"}"""
try {
Gobackend.readAudioMetadataJSON(tempPath)
} finally {
try { File(tempPath).delete() } catch (_: Exception) {}
}
val metadata = readAudioMetadataFromUri(uri)
?: return@withContext """{"error":"Failed to read SAF audio metadata"}"""
metadata.put("filePath", filePath)
metadata.toString()
} else {
Gobackend.readAudioMetadataJSON(filePath)
}
+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
}
]
}
+1 -3
View File
@@ -22,7 +22,7 @@ body = """
{% if commit.github.pr_number %} \
([#{{ commit.github.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.github.pr_number }}))\
{% endif %}\
{%- if commit.github.username %} by [@{{ commit.github.username }}](https://github.com/{{ commit.github.username }}){%- endif %}
{%- if commit.github.username and commit.github.username != "zarzet" %} by [@{{ commit.github.username }}](https://github.com/{{ commit.github.username }}){%- endif %}
{%- endfor %}
{% endfor %}
@@ -58,8 +58,6 @@ split_commits = false
# Regex for preprocessing the commit messages
commit_preprocessors = [
# Remove PR number from message (we add it back via GitHub integration)
{ pattern = '\(#(\d+)\)', replace = '' },
# Strip conventional commit prefix for cleaner messages
# (group header already shows the type)
]
+12 -1
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":
@@ -1595,6 +1602,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 +1622,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")
+27 -14
View File
@@ -114,7 +114,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
continue
}
// PERFORMER
if strings.HasPrefix(upper, "PERFORMER ") {
value := unquoteCue(line[len("PERFORMER "):])
if currentTrack != nil {
@@ -125,7 +124,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
continue
}
// TITLE
if strings.HasPrefix(upper, "TITLE ") {
value := unquoteCue(line[len("TITLE "):])
if currentTrack != nil {
@@ -136,7 +134,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
continue
}
// FILE
if strings.HasPrefix(upper, "FILE ") {
rest := line[len("FILE "):]
// Extract filename and type
@@ -148,7 +145,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
continue
}
// TRACK
if strings.HasPrefix(upper, "TRACK ") {
// Save previous track
if currentTrack != nil {
@@ -168,7 +164,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
continue
}
// INDEX
if strings.HasPrefix(upper, "INDEX ") && currentTrack != nil {
parts := strings.Fields(line)
if len(parts) >= 3 {
@@ -184,7 +179,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
continue
}
// ISRC
if strings.HasPrefix(upper, "ISRC ") && currentTrack != nil {
currentTrack.ISRC = strings.TrimSpace(line[len("ISRC "):])
continue
@@ -430,7 +424,15 @@ func ParseCueFileJSON(cuePath string, audioDir string) (string, error) {
// 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) {
return scanCueFileForLibraryInternal(cuePath, "", "", 0, scanTime)
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
@@ -441,23 +443,35 @@ func ScanCueFileForLibrary(cuePath string, scanTime string) ([]LibraryScanResult
// - 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) {
return scanCueFileForLibraryInternal(cuePath, audioDir, virtualPathPrefix, fileModTime, scanTime)
}
func scanCueFileForLibraryInternal(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)
}
// Resolve audio file — optionally in an overridden directory
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 nil, fmt.Errorf("audio file not found for cue: %s (referenced: %s)", cuePath, sheet.FileName)
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
@@ -540,7 +554,6 @@ func scanCueFileForLibraryInternal(cuePath, audioDir, virtualPathPrefix string,
duration = int(totalDurationSec - track.StartTime)
}
// Use a unique ID based on pathBase + track number
id := generateLibraryID(fmt.Sprintf("%s#track%d", pathBase, track.Number))
// Use a virtual file path that includes the track number to ensure
+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)
+331 -97
View File
@@ -135,6 +135,36 @@ type DownloadResult struct {
DecryptionKey string
}
func preferredReleaseMetadata(
req DownloadRequest,
album string,
releaseDate string,
trackNumber int,
discNumber int,
) (string, string, int, int) {
preferredAlbum := strings.TrimSpace(req.AlbumName)
if preferredAlbum == "" {
preferredAlbum = album
}
preferredReleaseDate := strings.TrimSpace(req.ReleaseDate)
if preferredReleaseDate == "" {
preferredReleaseDate = releaseDate
}
preferredTrackNumber := req.TrackNumber
if preferredTrackNumber == 0 {
preferredTrackNumber = trackNumber
}
preferredDiscNumber := req.DiscNumber
if preferredDiscNumber == 0 {
preferredDiscNumber = discNumber
}
return preferredAlbum, preferredReleaseDate, preferredTrackNumber, preferredDiscNumber
}
func buildDownloadSuccessResponse(
req DownloadRequest,
result DownloadResult,
@@ -153,25 +183,16 @@ func buildDownloadSuccessResponse(
artist = req.ArtistName
}
album := result.Album
if album == "" {
album = req.AlbumName
}
releaseDate := result.ReleaseDate
if releaseDate == "" {
releaseDate = req.ReleaseDate
}
trackNumber := result.TrackNumber
if trackNumber == 0 {
trackNumber = req.TrackNumber
}
discNumber := result.DiscNumber
if discNumber == 0 {
discNumber = req.DiscNumber
}
// Preserve requested release metadata when available so mixed-provider
// fallback downloads from the same source album do not get split into
// different albums just because Tidal/Qobuz report variant titles/dates.
album, releaseDate, trackNumber, discNumber := preferredReleaseMetadata(
req,
result.Album,
result.ReleaseDate,
result.TrackNumber,
result.DiscNumber,
)
isrc := result.ISRC
if isrc == "" {
@@ -262,7 +283,7 @@ func enrichRequestExtendedMetadata(req *DownloadRequest) {
return
}
if req.ISRC == "" || (req.Genre != "" && req.Label != "") {
if req.ISRC == "" || (req.Genre != "" && req.Label != "" && req.Copyright != "") {
return
}
@@ -284,8 +305,11 @@ func enrichRequestExtendedMetadata(req *DownloadRequest) {
if req.Label == "" && extMeta.Label != "" {
req.Label = extMeta.Label
}
if req.Genre != "" || req.Label != "" {
GoLog("[DownloadWithFallback] Extended metadata ready: genre=%s, label=%s\n", req.Genre, req.Label)
if req.Copyright == "" && extMeta.Copyright != "" {
req.Copyright = extMeta.Copyright
}
if req.Genre != "" || req.Label != "" || req.Copyright != "" {
GoLog("[DownloadWithFallback] Extended metadata ready: genre=%s, label=%s, copyright=%s\n", req.Genre, req.Label, req.Copyright)
}
}
@@ -1156,6 +1180,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 {
@@ -1175,6 +1259,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 {
@@ -1235,10 +1338,7 @@ func GetDeezerExtendedMetadata(trackID string) (string, error) {
return "", err
}
result := map[string]string{
"genre": metadata.Genre,
"label": metadata.Label,
}
result := buildDeezerExtendedMetadataResult(metadata)
jsonBytes, err := json.Marshal(result)
if err != nil {
@@ -1258,7 +1358,8 @@ func SearchDeezerByISRC(isrc string) (string, error) {
return "", err
}
jsonBytes, err := json.Marshal(track)
result := buildDeezerISRCSearchResult(track)
jsonBytes, err := json.Marshal(result)
if err != nil {
return "", err
}
@@ -1266,6 +1367,55 @@ func SearchDeezerByISRC(isrc string) (string, error) {
return string(jsonBytes), nil
}
func buildDeezerExtendedMetadataResult(metadata *AlbumExtendedMetadata) map[string]string {
if metadata == nil {
return map[string]string{
"genre": "",
"label": "",
"copyright": "",
}
}
return map[string]string{
"genre": metadata.Genre,
"label": metadata.Label,
"copyright": metadata.Copyright,
}
}
func buildDeezerISRCSearchResult(track *TrackMetadata) map[string]interface{} {
if track == nil {
return map[string]interface{}{}
}
result := map[string]interface{}{
"spotify_id": track.SpotifyID,
"artists": track.Artists,
"name": track.Name,
"album_name": track.AlbumName,
"album_artist": track.AlbumArtist,
"duration_ms": track.DurationMS,
"images": track.Images,
"release_date": track.ReleaseDate,
"track_number": track.TrackNumber,
"total_tracks": track.TotalTracks,
"disc_number": track.DiscNumber,
"external_urls": track.ExternalURL,
"isrc": track.ISRC,
"album_id": track.AlbumID,
"artist_id": track.ArtistID,
"album_type": track.AlbumType,
}
if deezerID := strings.TrimSpace(strings.TrimPrefix(track.SpotifyID, "deezer:")); deezerID != "" {
result["id"] = deezerID
result["track_id"] = deezerID
result["success"] = true
}
return result
}
func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
@@ -1311,7 +1461,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)
}
@@ -1545,6 +1694,8 @@ func ExtractCoverToFile(audioPath string, outputPath string) error {
if strings.HasSuffix(lower, ".flac") {
coverData, err = ExtractCoverArt(audioPath)
} else if strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".aac") {
coverData, err = extractCoverFromM4A(audioPath)
} else if strings.HasSuffix(lower, ".mp3") {
coverData, _, err = extractMP3CoverArt(audioPath)
} else if strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg") {
@@ -1675,83 +1826,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)
// 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)
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 {
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)
}
// 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)
}
}
// 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()
@@ -1762,7 +1888,10 @@ func ReEnrichFile(requestJSON string) (string, error) {
if req.Label == "" && extMeta.Label != "" {
req.Label = extMeta.Label
}
GoLog("[ReEnrich] Extended metadata: genre=%s, label=%s\n", req.Genre, req.Label)
if req.Copyright == "" && extMeta.Copyright != "" {
req.Copyright = extMeta.Copyright
}
GoLog("[ReEnrich] Extended metadata: genre=%s, label=%s, copyright=%s\n", req.Genre, req.Label, req.Copyright)
}
}
@@ -2149,6 +2278,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 {
@@ -2534,6 +2678,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 {
@@ -2783,6 +2949,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
}
@@ -2930,6 +3117,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 {
@@ -3087,6 +3313,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()
}
@@ -3098,3 +3328,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"])
}
}
+86
View File
@@ -0,0 +1,86 @@
package gobackend
import "testing"
func TestBuildDownloadSuccessResponsePrefersRequestedAlbumMetadata(t *testing.T) {
req := DownloadRequest{
TrackName: "Bonus Track",
ArtistName: "Artist",
AlbumName: "Album (Deluxe)",
AlbumArtist: "Artist",
ReleaseDate: "2024-01-01",
TrackNumber: 14,
DiscNumber: 1,
ISRC: "REQ123",
CoverURL: "https://example.com/cover.jpg",
Genre: "Pop",
Label: "Label",
Copyright: "Copyright",
}
result := DownloadResult{
Title: "Bonus Track",
Artist: "Artist",
Album: "Album",
ReleaseDate: "2023-12-01",
TrackNumber: 2,
DiscNumber: 9,
ISRC: "RES456",
}
resp := buildDownloadSuccessResponse(
req,
result,
"tidal",
"ok",
"/tmp/test.flac",
false,
)
if resp.Album != req.AlbumName {
t.Fatalf("album = %q, want %q", resp.Album, req.AlbumName)
}
if resp.ReleaseDate != req.ReleaseDate {
t.Fatalf("release date = %q, want %q", resp.ReleaseDate, req.ReleaseDate)
}
if resp.TrackNumber != req.TrackNumber {
t.Fatalf("track number = %d, want %d", resp.TrackNumber, req.TrackNumber)
}
if resp.DiscNumber != req.DiscNumber {
t.Fatalf("disc number = %d, want %d", resp.DiscNumber, req.DiscNumber)
}
if resp.Artist != result.Artist {
t.Fatalf("artist = %q, want provider artist %q", resp.Artist, result.Artist)
}
if resp.ISRC != result.ISRC {
t.Fatalf("isrc = %q, want provider isrc %q", resp.ISRC, result.ISRC)
}
}
func TestPreferredReleaseMetadataPrefersRequestValues(t *testing.T) {
album, releaseDate, trackNumber, discNumber := preferredReleaseMetadata(
DownloadRequest{
AlbumName: "Album (Deluxe Edition)",
ReleaseDate: "2024-01-01",
TrackNumber: 13,
DiscNumber: 2,
},
"Album",
"2023-01-01",
3,
1,
)
if album != "Album (Deluxe Edition)" {
t.Fatalf("album = %q", album)
}
if releaseDate != "2024-01-01" {
t.Fatalf("release date = %q", releaseDate)
}
if trackNumber != 13 {
t.Fatalf("track number = %d", trackNumber)
}
if discNumber != 2 {
t.Fatalf("disc number = %d", discNumber)
}
}
-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 {
+325 -7
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()
@@ -645,7 +676,7 @@ func SetMetadataProviderPriority(providerIDs []string) {
metadataProviderPriorityMu.Lock()
defer metadataProviderPriorityMu.Unlock()
sanitized := make([]string, 0, len(providerIDs)+1)
sanitized := make([]string, 0, len(providerIDs)+3)
seen := map[string]struct{}{}
for _, providerID := range providerIDs {
providerID = strings.TrimSpace(providerID)
@@ -658,8 +689,12 @@ func SetMetadataProviderPriority(providerIDs []string) {
seen[providerID] = struct{}{}
sanitized = append(sanitized, providerID)
}
if _, exists := seen["deezer"]; !exists {
sanitized = append([]string{"deezer"}, sanitized...)
for _, providerID := range []string{"deezer", "qobuz", "tidal"} {
if _, exists := seen[providerID]; exists {
continue
}
seen[providerID] = struct{}{}
sanitized = append(sanitized, providerID)
}
metadataProviderPriority = sanitized
@@ -671,7 +706,7 @@ func GetMetadataProviderPriority() []string {
defer metadataProviderPriorityMu.RUnlock()
if len(metadataProviderPriority) == 0 {
return []string{"deezer"}
return []string{"deezer", "qobuz", "tidal"}
}
result := make([]string, len(metadataProviderPriority))
@@ -688,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()
@@ -783,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
@@ -803,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)) {
@@ -896,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
}
@@ -946,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()
@@ -961,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)
}
@@ -1449,6 +1761,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)
}
}
+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) &&
+3 -3
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
@@ -350,7 +350,7 @@ func calculateNextDelay(currentDelay time.Duration, config RetryConfig) time.Dur
func getRetryAfterDuration(resp *http.Response) time.Duration {
retryAfter := resp.Header.Get("Retry-After")
if retryAfter == "" {
return 60 * time.Second // Default wait time
return 60 * time.Second
}
if seconds, err := strconv.Atoi(retryAfter); err == nil {
@@ -364,7 +364,7 @@ func getRetryAfterDuration(resp *http.Response) time.Duration {
}
}
return 60 * time.Second // Default
return 60 * time.Second
}
func ReadResponseBody(resp *http.Response) ([]byte, error) {
+172 -65
View File
@@ -1,10 +1,12 @@
package gobackend
import (
"bufio"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
@@ -71,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
@@ -144,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()
@@ -169,22 +171,29 @@ func ScanLibraryFolder(folderPath string) (string, error) {
// 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 _, filePath := range audioFiles {
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, filePath := range audioFiles {
for i, fileInfo := range audioFileInfos {
filePath := fileInfo.path
select {
case <-cancelCh:
return "[]", fmt.Errorf("scan cancelled")
@@ -201,7 +210,20 @@ func ScanLibraryFolder(folderPath string) (string, error) {
// Handle .cue files: produce multiple track results
if ext == ".cue" {
cueResults, err := ScanCueFileForLibrary(filePath, scanTime)
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)
@@ -219,7 +241,7 @@ func ScanLibraryFolder(folderPath string) (string, error) {
continue
}
result, err := scanAudioFile(filePath, scanTime)
result, err := scanAudioFileWithKnownModTime(filePath, scanTime, fileInfo.modTime)
if err != nil {
errorCount++
GoLog("[LibraryScan] Error scanning %s: %v\n", filePath, err)
@@ -245,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),
@@ -254,7 +284,9 @@ 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()
}
@@ -262,7 +294,7 @@ func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) {
coverCacheDir := libraryCoverCacheDir
libraryCoverCacheMu.RUnlock()
if coverCacheDir != "" && ext != ".m4a" {
coverPath, err := SaveCoverToCache(filePath, coverCacheDir)
coverPath, err := SaveCoverToCacheWithHint(filePath, displayNameHint, coverCacheDir)
if err == nil && coverPath != "" {
result.CoverPath = coverPath
}
@@ -276,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"
@@ -297,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
@@ -319,7 +367,7 @@ func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResul
}
}
applyDefaultLibraryMetadata(filePath, result)
applyDefaultLibraryMetadata(filePath, "", result)
return result, nil
}
@@ -331,14 +379,14 @@ func scanM4AFile(filePath string, result *LibraryScanResult) (*LibraryScanResult
result.SampleRate = quality.SampleRate
}
return scanFromFilename(filePath, result)
return scanFromFilename(filePath, "", result)
}
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
@@ -365,16 +413,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
@@ -397,13 +445,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 {
@@ -426,7 +475,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"
}
@@ -473,8 +522,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
}
@@ -490,7 +543,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")
}
@@ -503,13 +592,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()
@@ -541,14 +623,13 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
// Find files to scan (new or modified)
var filesToScan []libraryAudioFileInfo
skippedCount := 0
// Build a set of existing CUE virtual path base files for incremental matching.
// CUE tracks are stored with virtual paths like "/path/album.cue#track01".
// We need to match these against the actual .cue file's modTime.
cueBaseModTimes := make(map[string]int64) // base cue path -> modTime from disk
for _, f := range currentFiles {
if strings.ToLower(filepath.Ext(f.path)) == ".cue" {
cueBaseModTimes[f.path] = f.modTime
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
}
}
}
@@ -557,25 +638,12 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
if !exists {
// For .cue files, also check if any virtual path entries exist
if strings.ToLower(filepath.Ext(f.path)) == ".cue" {
hasCueTracks := false
for existingPath := range existingFiles {
if strings.HasPrefix(existingPath, f.path+"#track") {
hasCueTracks = true
break
}
}
if hasCueTracks {
if cueTrackModTime, hasCueTracks := existingCueTrackModTimes[f.path]; hasCueTracks {
// CUE file exists in DB via virtual paths; check if modTime changed
// Use modTime from any virtual path (they all share the same .cue modTime)
for existingPath, modTime := range existingFiles {
if strings.HasPrefix(existingPath, f.path+"#track") {
if f.modTime == modTime {
skippedCount++
} else {
filesToScan = append(filesToScan, f)
}
break
}
if f.modTime == cueTrackModTime {
skippedCount++
} else {
filesToScan = append(filesToScan, f)
}
continue
}
@@ -630,6 +698,7 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
// 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" {
@@ -637,6 +706,10 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
if err == nil && sheet.FileName != "" {
audioPath := ResolveCueAudioPath(f.path, sheet.FileName)
if audioPath != "" {
parsedCueFiles[f.path] = scannedCueFileInfo{
sheet: sheet,
audioPath: audioPath,
}
cueReferencedAudioFilesInc[audioPath] = true
}
}
@@ -660,7 +733,20 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
// Handle .cue files: produce multiple track results
if ext == ".cue" {
cueResults, err := ScanCueFileForLibrary(f.path, scanTime)
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)
@@ -675,7 +761,7 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
continue
}
result, err := scanAudioFile(f.path, scanTime)
result, err := scanAudioFileWithKnownModTime(f.path, scanTime, f.modTime)
if err != nil {
errorCount++
GoLog("[LibraryScan] Error scanning %s: %v\n", f.path, err)
@@ -709,3 +795,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)
}
+174 -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,153 @@ func ExtractLyrics(filePath string) (string, error) {
return extractLyricsFromSidecarLRC(filePath)
}
func extractLyricsFromM4A(filePath string) (string, error) {
f, err := os.Open(filePath)
if err != nil {
return "", err
}
defer f.Close()
fi, err := f.Stat()
if err != nil {
return "", err
}
fileSize := fi.Size()
moov, found, err := findAtomInRange(f, 0, fileSize, "moov", fileSize)
if err != nil || !found {
return "", fmt.Errorf("moov not found")
}
bodyStart := moov.offset + moov.headerSize
bodySize := moov.size - moov.headerSize
udta, found, err := findAtomInRange(f, bodyStart, bodySize, "udta", fileSize)
if err != nil || !found {
return "", fmt.Errorf("udta not found")
}
bodyStart = udta.offset + udta.headerSize
bodySize = udta.size - udta.headerSize
meta, found, err := findAtomInRange(f, bodyStart, bodySize, "meta", fileSize)
if err != nil || !found {
return "", fmt.Errorf("meta not found")
}
// meta atom has 4-byte version/flags after the header
bodyStart = meta.offset + meta.headerSize + 4
bodySize = meta.size - meta.headerSize - 4
ilst, found, err := findAtomInRange(f, bodyStart, bodySize, "ilst", fileSize)
if err != nil || !found {
return "", fmt.Errorf("ilst not found")
}
bodyStart = ilst.offset + ilst.headerSize
bodySize = ilst.size - ilst.headerSize
lyr, found, err := findAtomInRange(f, bodyStart, bodySize, "\xa9lyr", fileSize)
if err != nil || !found {
return "", fmt.Errorf("lyrics atom not found")
}
dataStart := lyr.offset + lyr.headerSize
dataSize := lyr.size - lyr.headerSize
dataAtom, found, err := findAtomInRange(f, dataStart, dataSize, "data", fileSize)
if err != nil || !found {
return "", fmt.Errorf("data atom not found in lyrics")
}
// data atom: 8 bytes header + 4 bytes type indicator + 4 bytes locale = skip 8
textStart := dataAtom.offset + dataAtom.headerSize + 8
textLen := dataAtom.size - dataAtom.headerSize - 8
if textLen <= 0 {
return "", fmt.Errorf("empty lyrics")
}
buf := make([]byte, textLen)
if _, err := f.ReadAt(buf, textStart); err != nil {
return "", err
}
return string(buf), nil
}
func extractCoverFromM4A(filePath string) ([]byte, error) {
f, err := os.Open(filePath)
if err != nil {
return nil, err
}
defer f.Close()
fi, err := f.Stat()
if err != nil {
return nil, err
}
fileSize := fi.Size()
moov, found, err := findAtomInRange(f, 0, fileSize, "moov", fileSize)
if err != nil || !found {
return nil, fmt.Errorf("moov not found")
}
bodyStart := moov.offset + moov.headerSize
bodySize := moov.size - moov.headerSize
udta, found, err := findAtomInRange(f, bodyStart, bodySize, "udta", fileSize)
if err != nil || !found {
return nil, fmt.Errorf("udta not found")
}
bodyStart = udta.offset + udta.headerSize
bodySize = udta.size - udta.headerSize
meta, found, err := findAtomInRange(f, bodyStart, bodySize, "meta", fileSize)
if err != nil || !found {
return nil, fmt.Errorf("meta not found")
}
bodyStart = meta.offset + meta.headerSize + 4
bodySize = meta.size - meta.headerSize - 4
ilst, found, err := findAtomInRange(f, bodyStart, bodySize, "ilst", fileSize)
if err != nil || !found {
return nil, fmt.Errorf("ilst not found")
}
bodyStart = ilst.offset + ilst.headerSize
bodySize = ilst.size - ilst.headerSize
covr, found, err := findAtomInRange(f, bodyStart, bodySize, "covr", fileSize)
if err != nil || !found {
return nil, fmt.Errorf("cover atom not found")
}
dataStart := covr.offset + covr.headerSize
dataSize := covr.size - covr.headerSize
dataAtom, found, err := findAtomInRange(f, dataStart, dataSize, "data", fileSize)
if err != nil || !found {
return nil, fmt.Errorf("data atom not found in cover")
}
// data atom: header + 4 bytes type indicator + 4 bytes locale
imgStart := dataAtom.offset + dataAtom.headerSize + 8
imgLen := dataAtom.size - dataAtom.headerSize - 8
if imgLen <= 0 {
return nil, fmt.Errorf("empty cover data")
}
buf := make([]byte, imgLen)
if _, err := f.ReadAt(buf, imgStart); err != nil {
return nil, err
}
return buf, nil
}
func extractLyricsFromSidecarLRC(filePath string) (string, error) {
ext := filepath.Ext(filePath)
base := strings.TrimSuffix(filePath, ext)
@@ -743,15 +898,28 @@ func GetM4AQuality(filePath string) (AudioQuality, error) {
return AudioQuality{}, err
}
buf := make([]byte, 24)
buf := make([]byte, 32)
if _, err := f.ReadAt(buf, sampleOffset); err != nil {
return AudioQuality{}, fmt.Errorf("failed to read audio sample entry: %w", err)
}
sampleRate := int(buf[22])<<8 | int(buf[23])
bitDepth := 16
if atomType == "alac" {
bitDepth = 24
// AudioSampleEntry layout from the box type field:
// [0:4] type ("mp4a"/"alac")
// [4:10] SampleEntry.reserved
// [10:12] data_reference_index
// [12:20] reserved[8]
// [20:22] channelcount
// [22:24] samplesize (bit depth)
// [24:26] pre_defined
// [26:28] reserved
// [28:32] samplerate (16.16 fixed-point)
sampleRate := int(buf[28])<<8 | int(buf[29])
bitDepth := int(buf[22])<<8 | int(buf[23])
if bitDepth <= 0 {
bitDepth = 16
if atomType == "alac" {
bitDepth = 24
}
}
return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate}, nil
@@ -874,7 +1042,7 @@ func findAudioSampleEntry(f *os.File, start, end, fileSize int64) (int64, string
if bestIdx >= 0 {
absolute := readPos - int64(len(tail)) + int64(bestIdx)
if absolute+24 > fileSize {
if absolute+32 > fileSize {
return 0, "", fmt.Errorf("audio info not found in M4A file")
}
return absolute, bestType, nil
+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 {
+750 -35
View File
@@ -28,21 +28,40 @@ type QobuzDownloader struct {
var (
globalQobuzDownloader *QobuzDownloader
qobuzDownloaderOnce sync.Once
qobuzGetTrackByIDFunc = func(q *QobuzDownloader, trackID int64) (*QobuzTrack, error) {
return q.GetTrackByID(trackID)
}
qobuzSearchTrackByISRCWithDurationFunc = func(q *QobuzDownloader, isrc string, expectedDurationSec int) (*QobuzTrack, error) {
return q.SearchTrackByISRCWithDuration(isrc, expectedDurationSec)
}
qobuzSearchTrackByMetadataWithDurationFunc = func(q *QobuzDownloader, trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) {
return q.SearchTrackByMetadataWithDuration(trackName, artistName, expectedDurationSec)
}
songLinkCheckTrackAvailabilityFunc = func(client *SongLinkClient, spotifyTrackID string, isrc string) (*TrackAvailability, error) {
return client.CheckTrackAvailability(spotifyTrackID, isrc)
}
)
const (
qobuzTrackGetBaseURL = "https://www.qobuz.com/api.json/0.2/track/get?track_id="
qobuzTrackSearchBaseURL = "https://www.qobuz.com/api.json/0.2/track/search?query="
qobuzAlbumGetBaseURL = "https://www.qobuz.com/api.json/0.2/album/get?album_id="
qobuzArtistGetBaseURL = "https://www.qobuz.com/api.json/0.2/artist/get?artist_id="
qobuzPlaylistGetBaseURL = "https://www.qobuz.com/api.json/0.2/playlist/get?playlist_id="
qobuzStoreSearchBaseURL = "https://www.qobuz.com/us-en/search/tracks/"
qobuzTrackPlayBaseURL = "https://play.qobuz.com/track/"
qobuzStoreBaseURL = "https://www.qobuz.com/us-en"
qobuzDownloadAPIURL = "https://www.musicdl.me/api/qobuz/download"
qobuzDabMusicAPIURL = "https://dabmusic.xyz/api/stream?trackId="
qobuzDeebAPIURL = "https://dab.yeet.su/api/stream?trackId="
qobuzAfkarAPIURL = "https://qbz.afkarxyz.qzz.io/api/track/"
qobuzSquidAPIURL = "https://qobuz.squid.wtf/api/download-music?country=US&track_id="
qobuzDebugKeyXORMask = byte(0x5A)
)
var qobuzStoreTrackIDRegex = regexp.MustCompile(`/v4/ajax/popin-add-cart/track/([0-9]+)`)
var qobuzArtistAlbumIDRegex = regexp.MustCompile(`data-itemtype="album"\s+data-itemId="([A-Za-z0-9]+)"`)
var qobuzLocaleSegmentRegex = regexp.MustCompile(`^[a-z]{2}-[a-z]{2}$`)
var qobuzDebugKeyObfuscated = []byte{
0x69, 0x3b, 0x38, 0x3e, 0x36, 0x37, 0x35, 0x2f, 0x36, 0x3b,
@@ -58,20 +77,406 @@ type QobuzTrack struct {
ISRC string `json:"isrc"`
Duration int `json:"duration"`
TrackNumber int `json:"track_number"`
MediaNumber int `json:"media_number"`
MaximumBitDepth int `json:"maximum_bit_depth"`
MaximumSamplingRate float64 `json:"maximum_sampling_rate"`
Version string `json:"version"`
Album struct {
ID string `json:"id"`
QobuzID int64 `json:"qobuz_id"`
TracksCount int `json:"tracks_count"`
Title string `json:"title"`
ReleaseDate string `json:"release_date_original"`
Image struct {
Large string `json:"large"`
ProductType string `json:"product_type"`
ReleaseType string `json:"release_type"`
Artist struct {
ID int64 `json:"id"`
Name string `json:"name"`
} `json:"artist"`
Artists []qobuzArtistRef `json:"artists"`
Image struct {
Thumbnail string `json:"thumbnail"`
Small string `json:"small"`
Large string `json:"large"`
} `json:"image"`
} `json:"album"`
Performer struct {
ID int64 `json:"id"`
Name string `json:"name"`
} `json:"performer"`
}
type qobuzImageSet struct {
Thumbnail string `json:"thumbnail"`
Small string `json:"small"`
Large string `json:"large"`
}
type qobuzArtistRef struct {
ID int64 `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
}
type qobuzLabelRef struct {
Name string `json:"name"`
}
type qobuzGenreRef struct {
Name string `json:"name"`
}
type qobuzAlbumDetails struct {
ID string `json:"id"`
QobuzID int64 `json:"qobuz_id"`
Title string `json:"title"`
ReleaseDateOriginal string `json:"release_date_original"`
TracksCount int `json:"tracks_count"`
ProductType string `json:"product_type"`
ReleaseType string `json:"release_type"`
Image qobuzImageSet `json:"image"`
Artist qobuzArtistRef `json:"artist"`
Artists []qobuzArtistRef `json:"artists"`
Genre qobuzGenreRef `json:"genre"`
Label qobuzLabelRef `json:"label"`
Copyright string `json:"copyright"`
Tracks struct {
Items []QobuzTrack `json:"items"`
} `json:"tracks"`
}
type qobuzArtistDetails struct {
ID int64 `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Image qobuzImageSet `json:"image"`
}
type qobuzPlaylistDetails struct {
ID int64 `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
ImageRectangle []string `json:"image_rectangle"`
ImageRectangleMini []string `json:"image_rectangle_mini"`
TracksCount int `json:"tracks_count"`
Owner struct {
ID int64 `json:"id"`
Name string `json:"name"`
} `json:"owner"`
Tracks struct {
Total int `json:"total"`
Offset int `json:"offset"`
Limit int `json:"limit"`
Items []QobuzTrack `json:"items"`
} `json:"tracks"`
}
func qobuzFirstNonEmpty(values ...string) string {
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed != "" {
return trimmed
}
}
return ""
}
func qobuzPrefixedID(id string) string {
trimmed := strings.TrimSpace(id)
if trimmed == "" {
return ""
}
if strings.HasPrefix(trimmed, "qobuz:") {
return trimmed
}
return "qobuz:" + trimmed
}
func qobuzPrefixedNumericID(id int64) string {
if id <= 0 {
return ""
}
return fmt.Sprintf("qobuz:%d", id)
}
func qobuzNormalizeReleaseDate(value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return ""
}
if _, err := time.Parse("2006-01-02", trimmed); err == nil {
return trimmed
}
if parsed, err := time.Parse("Jan 2, 2006", trimmed); err == nil {
return parsed.Format("2006-01-02")
}
return trimmed
}
func qobuzNormalizeAlbumType(releaseType, productType string, totalTracks int) string {
kind := strings.ToLower(strings.TrimSpace(releaseType))
if kind == "" {
kind = strings.ToLower(strings.TrimSpace(productType))
}
switch kind {
case "album", "single", "ep", "compilation":
return kind
}
if totalTracks > 0 && totalTracks <= 3 {
return "single"
}
return "album"
}
func qobuzArtistsDisplayName(artists []qobuzArtistRef, fallback string) string {
names := make([]string, 0, len(artists))
seen := make(map[string]struct{}, len(artists))
for _, artist := range artists {
name := strings.TrimSpace(artist.Name)
if name == "" {
continue
}
key := strings.ToLower(name)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
names = append(names, name)
}
if len(names) == 0 {
return strings.TrimSpace(fallback)
}
return strings.Join(names, ", ")
}
func qobuzTrackDisplayTitle(track *QobuzTrack) string {
if track == nil {
return ""
}
title := strings.TrimSpace(track.Title)
version := strings.TrimSpace(track.Version)
if title == "" || version == "" {
return title
}
return fmt.Sprintf("%s (%s)", title, version)
}
func qobuzTrackAlbumImage(track *QobuzTrack) string {
if track == nil {
return ""
}
return qobuzFirstNonEmpty(
track.Album.Image.Large,
track.Album.Image.Small,
track.Album.Image.Thumbnail,
)
}
func qobuzAlbumImage(album *qobuzAlbumDetails) string {
if album == nil {
return ""
}
return qobuzFirstNonEmpty(
album.Image.Large,
album.Image.Small,
album.Image.Thumbnail,
)
}
func qobuzTrackArtistID(track *QobuzTrack) string {
if track == nil {
return ""
}
if track.Performer.ID > 0 {
return qobuzPrefixedNumericID(track.Performer.ID)
}
return qobuzPrefixedNumericID(track.Album.Artist.ID)
}
func qobuzTrackArtistName(track *QobuzTrack) string {
if track == nil {
return ""
}
return strings.TrimSpace(track.Performer.Name)
}
func qobuzTrackAlbumArtist(track *QobuzTrack) string {
if track == nil {
return ""
}
return qobuzArtistsDisplayName(track.Album.Artists, track.Album.Artist.Name)
}
func qobuzTrackAlbumType(track *QobuzTrack) string {
if track == nil {
return "album"
}
return qobuzNormalizeAlbumType(
track.Album.ReleaseType,
track.Album.ProductType,
track.Album.TracksCount,
)
}
func qobuzTrackToTrackMetadata(track *QobuzTrack) TrackMetadata {
if track == nil {
return TrackMetadata{}
}
return TrackMetadata{
SpotifyID: qobuzPrefixedNumericID(track.ID),
Artists: qobuzTrackArtistName(track),
Name: qobuzTrackDisplayTitle(track),
AlbumName: strings.TrimSpace(track.Album.Title),
AlbumArtist: qobuzTrackAlbumArtist(track),
DurationMS: track.Duration * 1000,
Images: qobuzTrackAlbumImage(track),
ReleaseDate: qobuzNormalizeReleaseDate(track.Album.ReleaseDate),
TrackNumber: track.TrackNumber,
TotalTracks: track.Album.TracksCount,
DiscNumber: track.MediaNumber,
ExternalURL: fmt.Sprintf("%s%d", qobuzTrackPlayBaseURL, track.ID),
ISRC: strings.TrimSpace(track.ISRC),
AlbumID: qobuzPrefixedID(track.Album.ID),
ArtistID: qobuzTrackArtistID(track),
AlbumType: qobuzTrackAlbumType(track),
}
}
func qobuzTrackToAlbumTrackMetadata(track *QobuzTrack) AlbumTrackMetadata {
if track == nil {
return AlbumTrackMetadata{}
}
return AlbumTrackMetadata{
SpotifyID: qobuzPrefixedNumericID(track.ID),
Artists: qobuzTrackArtistName(track),
Name: qobuzTrackDisplayTitle(track),
AlbumName: strings.TrimSpace(track.Album.Title),
AlbumArtist: qobuzTrackAlbumArtist(track),
DurationMS: track.Duration * 1000,
Images: qobuzTrackAlbumImage(track),
ReleaseDate: qobuzNormalizeReleaseDate(track.Album.ReleaseDate),
TrackNumber: track.TrackNumber,
TotalTracks: track.Album.TracksCount,
DiscNumber: track.MediaNumber,
ExternalURL: fmt.Sprintf("%s%d", qobuzTrackPlayBaseURL, track.ID),
ISRC: strings.TrimSpace(track.ISRC),
AlbumID: qobuzPrefixedID(track.Album.ID),
AlbumURL: fmt.Sprintf("https://play.qobuz.com/album/%s", strings.TrimSpace(track.Album.ID)),
AlbumType: qobuzTrackAlbumType(track),
}
}
func qobuzAlbumToAlbumInfo(album *qobuzAlbumDetails) AlbumInfoMetadata {
if album == nil {
return AlbumInfoMetadata{}
}
return AlbumInfoMetadata{
TotalTracks: album.TracksCount,
Name: strings.TrimSpace(album.Title),
ReleaseDate: qobuzNormalizeReleaseDate(album.ReleaseDateOriginal),
Artists: qobuzArtistsDisplayName(album.Artists, album.Artist.Name),
ArtistId: qobuzPrefixedNumericID(album.Artist.ID),
Images: qobuzAlbumImage(album),
Genre: strings.TrimSpace(album.Genre.Name),
Label: strings.TrimSpace(album.Label.Name),
Copyright: strings.TrimSpace(album.Copyright),
}
}
func qobuzAlbumToArtistAlbum(album *qobuzAlbumDetails) ArtistAlbumMetadata {
if album == nil {
return ArtistAlbumMetadata{}
}
return ArtistAlbumMetadata{
ID: qobuzPrefixedID(album.ID),
Name: strings.TrimSpace(album.Title),
ReleaseDate: qobuzNormalizeReleaseDate(album.ReleaseDateOriginal),
TotalTracks: album.TracksCount,
Images: qobuzAlbumImage(album),
AlbumType: qobuzNormalizeAlbumType(album.ReleaseType, album.ProductType, album.TracksCount),
Artists: qobuzArtistsDisplayName(album.Artists, album.Artist.Name),
}
}
func qobuzSplitPathSegments(path string) []string {
rawSegments := strings.Split(strings.TrimSpace(path), "/")
segments := make([]string, 0, len(rawSegments))
for _, segment := range rawSegments {
trimmed := strings.TrimSpace(segment)
if trimmed == "" {
continue
}
segments = append(segments, trimmed)
}
if len(segments) > 0 && qobuzLocaleSegmentRegex.MatchString(strings.ToLower(segments[0])) {
return segments[1:]
}
return segments
}
func qobuzResourceTypeFromSegment(segment string) string {
switch strings.ToLower(strings.TrimSpace(segment)) {
case "album":
return "album"
case "interpreter", "artist":
return "artist"
case "playlist", "playlists":
return "playlist"
case "track":
return "track"
default:
return ""
}
}
func parseQobuzURL(input string) (string, string, error) {
raw := strings.TrimSpace(input)
if raw == "" {
return "", "", fmt.Errorf("empty Qobuz URL")
}
if strings.HasPrefix(strings.ToLower(raw), "qobuzapp://") {
parsed, err := url.Parse(raw)
if err != nil {
return "", "", err
}
resourceType := qobuzResourceTypeFromSegment(parsed.Host)
resourceID := strings.Trim(strings.TrimSpace(parsed.Path), "/")
if resourceType == "" || resourceID == "" {
return "", "", fmt.Errorf("invalid or unsupported Qobuz URL")
}
return resourceType, resourceID, nil
}
parsed, err := url.Parse(raw)
if err != nil || parsed.Host == "" {
if !strings.Contains(raw, "://") {
parsed, err = url.Parse("https://" + raw)
}
}
if err != nil || parsed == nil || parsed.Host == "" {
return "", "", fmt.Errorf("invalid or unsupported Qobuz URL")
}
host := strings.ToLower(parsed.Host)
if host != "qobuz.com" && host != "www.qobuz.com" && host != "play.qobuz.com" {
return "", "", fmt.Errorf("invalid or unsupported Qobuz URL")
}
segments := qobuzSplitPathSegments(parsed.Path)
if len(segments) < 2 {
return "", "", fmt.Errorf("invalid or unsupported Qobuz URL")
}
resourceType := qobuzResourceTypeFromSegment(segments[0])
resourceID := strings.TrimSpace(segments[len(segments)-1])
if resourceType == "" || resourceID == "" {
return "", "", fmt.Errorf("invalid or unsupported Qobuz URL")
}
return resourceType, resourceID, nil
}
func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
@@ -386,9 +791,239 @@ func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) {
return &track, nil
}
func (q *QobuzDownloader) getQobuzJSON(requestURL string, target interface{}) error {
req, err := http.NewRequest("GET", requestURL, nil)
if err != nil {
return err
}
resp, err := DoRequestWithUserAgent(q.client, req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return fmt.Errorf("qobuz request failed: HTTP %d (%s)", resp.StatusCode, strings.TrimSpace(string(body)))
}
return json.NewDecoder(resp.Body).Decode(target)
}
func (q *QobuzDownloader) getQobuzBody(requestURL string) ([]byte, error) {
req, err := http.NewRequest("GET", requestURL, nil)
if err != nil {
return nil, err
}
resp, err := DoRequestWithUserAgent(q.client, req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return nil, fmt.Errorf("qobuz request failed: HTTP %d (%s)", resp.StatusCode, strings.TrimSpace(string(body)))
}
return io.ReadAll(resp.Body)
}
func extractQobuzAlbumIDsFromArtistHTML(body []byte) []string {
matches := qobuzArtistAlbumIDRegex.FindAllSubmatch(body, -1)
if len(matches) == 0 {
return nil
}
albumIDs := make([]string, 0, len(matches))
seen := make(map[string]struct{}, len(matches))
for _, match := range matches {
if len(match) < 2 {
continue
}
albumID := strings.TrimSpace(string(match[1]))
if albumID == "" {
continue
}
if _, ok := seen[albumID]; ok {
continue
}
seen[albumID] = struct{}{}
albumIDs = append(albumIDs, albumID)
}
return albumIDs
}
func (q *QobuzDownloader) getAlbumDetails(albumID string) (*qobuzAlbumDetails, error) {
requestURL := fmt.Sprintf("%s%s&app_id=%s", qobuzAlbumGetBaseURL, url.QueryEscape(strings.TrimSpace(albumID)), q.appID)
var album qobuzAlbumDetails
if err := q.getQobuzJSON(requestURL, &album); err != nil {
return nil, err
}
return &album, nil
}
func (q *QobuzDownloader) getArtistDetails(artistID string) (*qobuzArtistDetails, error) {
requestURL := fmt.Sprintf("%s%s&app_id=%s", qobuzArtistGetBaseURL, url.QueryEscape(strings.TrimSpace(artistID)), q.appID)
var artist qobuzArtistDetails
if err := q.getQobuzJSON(requestURL, &artist); err != nil {
return nil, err
}
return &artist, nil
}
func (q *QobuzDownloader) getPlaylistDetailsPage(playlistID string, limit, offset int) (*qobuzPlaylistDetails, error) {
requestURL := fmt.Sprintf(
"%s%s&extra=tracks&limit=%d&offset=%d&app_id=%s",
qobuzPlaylistGetBaseURL,
url.QueryEscape(strings.TrimSpace(playlistID)),
limit,
offset,
q.appID,
)
var playlist qobuzPlaylistDetails
if err := q.getQobuzJSON(requestURL, &playlist); err != nil {
return nil, err
}
return &playlist, nil
}
func (q *QobuzDownloader) getArtistAlbumIDs(artistID string) ([]string, error) {
artist, err := q.getArtistDetails(artistID)
if err != nil {
return nil, err
}
slug := strings.TrimSpace(artist.Slug)
if slug == "" {
slug = "artist"
}
requestURL := fmt.Sprintf("%s/interpreter/%s/%d", qobuzStoreBaseURL, url.PathEscape(slug), artist.ID)
body, err := q.getQobuzBody(requestURL)
if err != nil {
return nil, err
}
albumIDs := extractQobuzAlbumIDsFromArtistHTML(body)
if len(albumIDs) == 0 {
return nil, fmt.Errorf("artist page did not contain album IDs")
}
return albumIDs, nil
}
func (q *QobuzDownloader) GetTrackMetadata(resourceID string) (*TrackResponse, error) {
trackID, err := strconv.ParseInt(strings.TrimSpace(resourceID), 10, 64)
if err != nil || trackID <= 0 {
return nil, fmt.Errorf("invalid Qobuz track ID: %s", resourceID)
}
track, err := q.GetTrackByID(trackID)
if err != nil {
return nil, err
}
return &TrackResponse{Track: qobuzTrackToTrackMetadata(track)}, nil
}
func (q *QobuzDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePayload, error) {
album, err := q.getAlbumDetails(resourceID)
if err != nil {
return nil, err
}
tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Items))
for i := range album.Tracks.Items {
tracks = append(tracks, qobuzTrackToAlbumTrackMetadata(&album.Tracks.Items[i]))
}
return &AlbumResponsePayload{
AlbumInfo: qobuzAlbumToAlbumInfo(album),
TrackList: tracks,
}, nil
}
func (q *QobuzDownloader) GetPlaylistMetadata(resourceID string) (*PlaylistResponsePayload, error) {
const pageSize = 50
offset := 0
var playlistInfo PlaylistInfoMetadata
tracks := make([]AlbumTrackMetadata, 0, pageSize)
for {
page, err := q.getPlaylistDetailsPage(resourceID, pageSize, offset)
if err != nil {
return nil, err
}
if offset == 0 {
total := page.Tracks.Total
if total == 0 {
total = page.TracksCount
}
playlistInfo.Tracks.Total = total
playlistInfo.Owner.DisplayName = strings.TrimSpace(page.Owner.Name)
playlistInfo.Owner.Name = strings.TrimSpace(page.Name)
playlistInfo.Owner.Images = qobuzFirstNonEmpty(page.ImageRectangle...)
}
for i := range page.Tracks.Items {
tracks = append(tracks, qobuzTrackToAlbumTrackMetadata(&page.Tracks.Items[i]))
}
if len(page.Tracks.Items) == 0 ||
offset+len(page.Tracks.Items) >= playlistInfo.Tracks.Total ||
len(page.Tracks.Items) < pageSize {
break
}
offset += len(page.Tracks.Items)
}
return &PlaylistResponsePayload{
PlaylistInfo: playlistInfo,
TrackList: tracks,
}, nil
}
func (q *QobuzDownloader) GetArtistMetadata(resourceID string) (*ArtistResponsePayload, error) {
artist, err := q.getArtistDetails(resourceID)
if err != nil {
return nil, err
}
albumIDs, err := q.getArtistAlbumIDs(resourceID)
if err != nil {
return nil, err
}
albums := make([]ArtistAlbumMetadata, 0, len(albumIDs))
for _, albumID := range albumIDs {
album, albumErr := q.getAlbumDetails(albumID)
if albumErr != nil {
GoLog("[Qobuz] Skipping artist album %s: %v\n", albumID, albumErr)
continue
}
albums = append(albums, qobuzAlbumToArtistAlbum(album))
}
return &ArtistResponsePayload{
ArtistInfo: ArtistInfoMetadata{
ID: qobuzPrefixedNumericID(artist.ID),
Name: strings.TrimSpace(artist.Name),
Images: qobuzFirstNonEmpty(artist.Image.Large, artist.Image.Small, artist.Image.Thumbnail),
},
Albums: albums,
}, nil
}
func (q *QobuzDownloader) GetAvailableAPIs() []string {
return []string{
qobuzDownloadAPIURL,
qobuzDabMusicAPIURL,
qobuzDeebAPIURL,
qobuzAfkarAPIURL,
qobuzSquidAPIURL,
}
}
@@ -409,6 +1044,8 @@ func (q *QobuzDownloader) GetAvailableProviders() []qobuzAPIProvider {
{Name: "dabmusic", URL: qobuzDabMusicAPIURL, Kind: qobuzAPIKindStandard},
// "deeb" is mapped from the legacy reference fallback endpoint.
{Name: "deeb", URL: qobuzDeebAPIURL, Kind: qobuzAPIKindStandard},
// "qbz" comes from the desktop reference app and uses /api/track/{id}?quality=...
{Name: "qbz", URL: qobuzAfkarAPIURL, Kind: qobuzAPIKindStandard},
{Name: "squid", URL: qobuzSquidAPIURL, Kind: qobuzAPIKindStandard},
}
}
@@ -648,6 +1285,27 @@ func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*
return q.SearchTrackByMetadataWithDuration(trackName, artistName, 0)
}
func (q *QobuzDownloader) SearchTracks(query string, limit int) ([]ExtTrackMetadata, error) {
cleanQuery := strings.TrimSpace(query)
if cleanQuery == "" {
return nil, fmt.Errorf("empty qobuz search query")
}
if limit <= 0 {
limit = 20
}
tracks, err := q.searchQobuzTracksWithFallback(cleanQuery, limit)
if err != nil {
return nil, err
}
results := make([]ExtTrackMetadata, 0, len(tracks))
for i := range tracks {
results = append(results, normalizeBuiltInMetadataTrack(qobuzTrackToTrackMetadata(&tracks[i]), "qobuz"))
}
return results, nil
}
func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) {
queries := []string{}
@@ -791,6 +1449,39 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
return nil, fmt.Errorf("no matching track found for: %s - %s", artistName, trackName)
}
func qobuzTrackMatchesRequest(req DownloadRequest, track *QobuzTrack, logPrefix, source string) bool {
if track == nil {
return false
}
if req.ArtistName != "" && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
GoLog("[%s] Artist mismatch from %s: expected '%s', got '%s'. Rejecting.\n",
logPrefix, source, req.ArtistName, track.Performer.Name)
return false
}
if req.TrackName != "" && !qobuzTitlesMatch(req.TrackName, track.Title) {
GoLog("[%s] Title mismatch from %s: expected '%s', got '%s'. Rejecting.\n",
logPrefix, source, req.TrackName, track.Title)
return false
}
expectedDurationSec := req.DurationMS / 1000
if expectedDurationSec > 0 && track.Duration > 0 {
durationDiff := track.Duration - expectedDurationSec
if durationDiff < 0 {
durationDiff = -durationDiff
}
if durationDiff > 10 {
GoLog("[%s] Duration mismatch from %s: expected %ds, got %ds. Rejecting.\n",
logPrefix, source, expectedDurationSec, track.Duration)
return false
}
}
return true
}
func (q *QobuzDownloader) searchQobuzTracksViaAPI(query string, limit int) ([]QobuzTrack, error) {
searchURL := fmt.Sprintf("%s%s&limit=%d&app_id=%s", qobuzTrackSearchBaseURL, url.QueryEscape(query), limit, q.appID)
req, err := http.NewRequest("GET", searchURL, nil)
@@ -1247,6 +1938,19 @@ type QobuzDownloadResult struct {
LyricsLRC string
}
func parseQobuzRequestTrackID(raw string) int64 {
trimmed := strings.TrimSpace(raw)
trimmed = strings.TrimPrefix(trimmed, "qobuz:")
if trimmed == "" {
return 0
}
var trackID int64
if _, err := fmt.Sscanf(trimmed, "%d", &trackID); err != nil || trackID <= 0 {
return 0
}
return trackID
}
func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloader, logPrefix string) (*QobuzTrack, error) {
if downloader == nil {
downloader = NewQobuzDownloader()
@@ -1260,17 +1964,20 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade
var track *QobuzTrack
var err error
// Strategy 1: Use Qobuz ID from Odesli enrichment (fastest, most accurate)
// Strategy 1: Use Qobuz ID from request payload (fastest, most accurate)
if req.QobuzID != "" {
GoLog("[%s] Using Qobuz ID from Odesli enrichment: %s\n", logPrefix, req.QobuzID)
var trackID int64
if _, parseErr := fmt.Sscanf(req.QobuzID, "%d", &trackID); parseErr == nil && trackID > 0 {
track, err = downloader.GetTrackByID(trackID)
GoLog("[%s] Using Qobuz ID from request payload: %s\n", logPrefix, req.QobuzID)
if trackID := parseQobuzRequestTrackID(req.QobuzID); trackID > 0 {
track, err = qobuzGetTrackByIDFunc(downloader, trackID)
if err != nil {
GoLog("[%s] Failed to get track by Odesli ID %d: %v\n", logPrefix, trackID, err)
GoLog("[%s] Failed to get track by request Qobuz ID %d: %v\n", logPrefix, trackID, err)
track = nil
} else if track != nil {
GoLog("[%s] Successfully found track via Odesli ID: '%s' by '%s'\n", logPrefix, track.Title, track.Performer.Name)
if qobuzTrackMatchesRequest(req, track, logPrefix, "request Qobuz ID") {
GoLog("[%s] Successfully found track via request Qobuz ID: '%s' by '%s'\n", logPrefix, track.Title, track.Performer.Name)
} else {
track = nil
}
}
}
}
@@ -1279,10 +1986,12 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade
if track == nil && req.ISRC != "" {
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.QobuzTrackID > 0 {
GoLog("[%s] Cache hit! Using cached track ID: %d\n", logPrefix, cached.QobuzTrackID)
track, err = downloader.GetTrackByID(cached.QobuzTrackID)
track, err = qobuzGetTrackByIDFunc(downloader, cached.QobuzTrackID)
if err != nil {
GoLog("[%s] Cache hit but GetTrackByID failed: %v\n", logPrefix, err)
track = nil
} else if track != nil && !qobuzTrackMatchesRequest(req, track, logPrefix, "cached Qobuz ID") {
track = nil
}
}
}
@@ -1291,19 +2000,23 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade
if track == nil && req.SpotifyID != "" && req.QobuzID == "" {
GoLog("[%s] Trying to get Qobuz ID from SongLink for Spotify ID: %s\n", logPrefix, req.SpotifyID)
songLinkClient := NewSongLinkClient()
availability, slErr := songLinkClient.CheckTrackAvailability(req.SpotifyID, req.ISRC)
availability, slErr := songLinkCheckTrackAvailabilityFunc(songLinkClient, req.SpotifyID, req.ISRC)
if slErr == nil && availability != nil && availability.QobuzID != "" {
var trackID int64
if _, parseErr := fmt.Sscanf(availability.QobuzID, "%d", &trackID); parseErr == nil && trackID > 0 {
GoLog("[%s] Got Qobuz ID %d from SongLink\n", logPrefix, trackID)
track, err = downloader.GetTrackByID(trackID)
track, err = qobuzGetTrackByIDFunc(downloader, trackID)
if err != nil {
GoLog("[%s] Failed to get track by SongLink ID %d: %v\n", logPrefix, trackID, err)
track = nil
} else if track != nil {
GoLog("[%s] Successfully found track via SongLink ID: '%s' by '%s'\n", logPrefix, track.Title, track.Performer.Name)
if req.ISRC != "" {
GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
if qobuzTrackMatchesRequest(req, track, logPrefix, "SongLink Qobuz ID") {
GoLog("[%s] Successfully found track via SongLink ID: '%s' by '%s'\n", logPrefix, track.Title, track.Performer.Name)
if req.ISRC != "" {
GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
}
} else {
track = nil
}
}
}
@@ -1313,27 +2026,17 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade
// Strategy 4: ISRC search with duration verification
if track == nil && req.ISRC != "" {
GoLog("[%s] Trying ISRC search: %s\n", logPrefix, req.ISRC)
track, err = downloader.SearchTrackByISRCWithDuration(req.ISRC, expectedDurationSec)
if track != nil {
if !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
GoLog("[%s] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
logPrefix, req.ArtistName, track.Performer.Name)
track = nil
} else if !qobuzTitlesMatch(req.TrackName, track.Title) {
GoLog("[%s] Title mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
logPrefix, req.TrackName, track.Title)
track = nil
}
track, err = qobuzSearchTrackByISRCWithDurationFunc(downloader, req.ISRC, expectedDurationSec)
if track != nil && !qobuzTrackMatchesRequest(req, track, logPrefix, "ISRC search") {
track = nil
}
}
// Strategy 5: Metadata search with strict matching (duration tolerance: 10 seconds)
if track == nil {
GoLog("[%s] Trying metadata search: '%s' by '%s'\n", logPrefix, req.TrackName, req.ArtistName)
track, err = downloader.SearchTrackByMetadataWithDuration(req.TrackName, req.ArtistName, expectedDurationSec)
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
GoLog("[%s] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
logPrefix, req.ArtistName, track.Performer.Name)
track, err = qobuzSearchTrackByMetadataWithDurationFunc(downloader, req.TrackName, req.ArtistName, expectedDurationSec)
if track != nil && !qobuzTrackMatchesRequest(req, track, logPrefix, "metadata search") {
track = nil
}
}
@@ -1460,6 +2163,10 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
if req.AlbumName != "" {
albumName = req.AlbumName
}
releaseDate := track.Album.ReleaseDate
if req.ReleaseDate != "" {
releaseDate = req.ReleaseDate
}
actualTrackNumber := req.TrackNumber
if actualTrackNumber == 0 {
@@ -1471,7 +2178,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
Artist: track.Performer.Name,
Album: albumName,
AlbumArtist: req.AlbumArtist,
Date: track.Album.ReleaseDate,
Date: releaseDate,
TrackNumber: actualTrackNumber,
TotalTracks: req.TotalTracks,
DiscNumber: req.DiscNumber,
@@ -1535,16 +2242,24 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
lyricsLRC = parallelResult.LyricsLRC
}
resultAlbum, resultReleaseDate, resultTrackNumber, resultDiscNumber := preferredReleaseMetadata(
req,
track.Album.Title,
track.Album.ReleaseDate,
actualTrackNumber,
req.DiscNumber,
)
return QobuzDownloadResult{
FilePath: outputPath,
BitDepth: actualBitDepth,
SampleRate: actualSampleRate,
Title: track.Title,
Artist: track.Performer.Name,
Album: track.Album.Title,
ReleaseDate: track.Album.ReleaseDate,
TrackNumber: actualTrackNumber,
DiscNumber: req.DiscNumber,
Album: resultAlbum,
ReleaseDate: resultReleaseDate,
TrackNumber: resultTrackNumber,
DiscNumber: resultDiscNumber,
ISRC: track.ISRC,
LyricsLRC: lyricsLRC,
}, nil
+280 -2
View File
@@ -2,6 +2,95 @@ package gobackend
import "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) {
body := []byte(`{"success":true,"download_url":"https://example.test/new.flac","bit_depth":24,"sampling_rate":96}`)
@@ -106,16 +195,34 @@ func TestGetQobuzDebugKey(t *testing.T) {
}
}
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 +240,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)
}
}
+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.
+2
View File
@@ -157,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"`
+803 -56
View File
@@ -14,6 +14,7 @@ import (
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
@@ -32,6 +33,12 @@ var (
const (
spotifyTrackBaseURL = "https://open.spotify.com/track/"
songLinkLookupBaseURL = "https://api.song.link/v1-alpha.1/links?url="
tidalPublicAPIBaseURL = "https://tidal.com/v1"
tidalPublicToken = "txNoH4kkV41MfH25"
tidalResourceBaseURL = "https://resources.tidal.com"
tidalCountryCode = "US"
tidalLocale = "en_US"
tidalDeviceType = "BROWSER"
)
type TidalTrack struct {
@@ -43,19 +50,28 @@ type TidalTrack struct {
VolumeNumber int `json:"volumeNumber"`
Duration int `json:"duration"`
Album struct {
ID int64 `json:"id"`
Title string `json:"title"`
Cover string `json:"cover"`
ReleaseDate string `json:"releaseDate"`
URL string `json:"url"`
} `json:"album"`
Artists []struct {
Name string `json:"name"`
ID int64 `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Picture string `json:"picture"`
} `json:"artists"`
Artist struct {
Name string `json:"name"`
ID int64 `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Picture string `json:"picture"`
} `json:"artist"`
MediaMetadata struct {
Tags []string `json:"tags"`
} `json:"mediaMetadata"`
URL string `json:"url"`
}
type TidalAPIResponseV2 struct {
@@ -100,6 +116,105 @@ type MPD struct {
} `xml:"Period"`
}
type tidalPublicArtist struct {
ID int64 `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Picture string `json:"picture"`
}
type tidalPublicAlbum struct {
ID int64 `json:"id"`
Title string `json:"title"`
Type string `json:"type"`
Cover string `json:"cover"`
ReleaseDate string `json:"releaseDate"`
URL string `json:"url"`
NumberOfTracks int `json:"numberOfTracks"`
Explicit bool `json:"explicit"`
Artists []tidalPublicArtist `json:"artists"`
}
type tidalPublicAlbumPage struct {
Rows []struct {
Modules []struct {
Type string `json:"type"`
Album tidalPublicAlbum `json:"album"`
PagedList struct {
DataAPIPath string `json:"dataApiPath"`
Limit int `json:"limit"`
Offset int `json:"offset"`
TotalNumberOfItems int `json:"totalNumberOfItems"`
Items []struct {
Item TidalTrack `json:"item"`
Type string `json:"type"`
} `json:"items"`
} `json:"pagedList"`
} `json:"modules"`
} `json:"rows"`
}
type tidalPublicArtistPage struct {
Rows []struct {
Modules []struct {
Type string `json:"type"`
Title string `json:"title"`
Artist struct {
ID int64 `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
Picture string `json:"picture"`
} `json:"artist"`
PagedList struct {
DataAPIPath string `json:"dataApiPath"`
Limit int `json:"limit"`
Offset int `json:"offset"`
TotalNumberOfItems int `json:"totalNumberOfItems"`
Items []tidalPublicAlbum `json:"items"`
} `json:"pagedList"`
} `json:"modules"`
} `json:"rows"`
}
type tidalPublicArtistAlbumsPage struct {
Limit int `json:"limit"`
Offset int `json:"offset"`
TotalNumberOfItems int `json:"totalNumberOfItems"`
Items []tidalPublicAlbum `json:"items"`
}
type tidalPublicPlaylist struct {
UUID string `json:"uuid"`
Title string `json:"title"`
Description string `json:"description"`
Type string `json:"type"`
URL string `json:"url"`
Image string `json:"image"`
SquareImage string `json:"squareImage"`
NumberOfTracks int `json:"numberOfTracks"`
Creator struct {
ID int64 `json:"id"`
Name string `json:"name"`
} `json:"creator"`
}
type tidalPublicPlaylistItemsPage struct {
Limit int `json:"limit"`
Offset int `json:"offset"`
TotalNumberOfItems int `json:"totalNumberOfItems"`
Items []struct {
Item TidalTrack `json:"item"`
Type string `json:"type"`
} `json:"items"`
}
type tidalPublicTrackSearchResponse struct {
Limit int `json:"limit"`
Offset int `json:"offset"`
TotalNumberOfItems int `json:"totalNumberOfItems"`
Items []TidalTrack `json:"items"`
}
func NewTidalDownloader() *TidalDownloader {
tidalDownloaderOnce.Do(func() {
globalTidalDownloader = &TidalDownloader{
@@ -114,6 +229,457 @@ func NewTidalDownloader() *TidalDownloader {
return globalTidalDownloader
}
func tidalPrefixedID(id string) string {
trimmed := strings.TrimSpace(id)
if trimmed == "" {
return ""
}
return "tidal:" + trimmed
}
func tidalPrefixedNumericID(id int64) string {
if id <= 0 {
return ""
}
return fmt.Sprintf("tidal:%d", id)
}
func tidalImageURL(imageID, size string) string {
normalizedID := strings.TrimSpace(imageID)
if normalizedID == "" || strings.TrimSpace(size) == "" {
return ""
}
return fmt.Sprintf(
"%s/images/%s/%s.jpg",
tidalResourceBaseURL,
strings.ReplaceAll(normalizedID, "-", "/"),
size,
)
}
func tidalFirstNonEmpty(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}
func tidalJoinArtistNames(artists []tidalPublicArtist) string {
if len(artists) == 0 {
return ""
}
names := make([]string, 0, len(artists))
for _, artist := range artists {
if trimmed := strings.TrimSpace(artist.Name); trimmed != "" {
names = append(names, trimmed)
}
}
return strings.Join(names, ", ")
}
func tidalTrackArtistsDisplay(track *TidalTrack) string {
if track == nil {
return ""
}
if len(track.Artists) > 0 {
names := make([]string, 0, len(track.Artists))
for _, artist := range track.Artists {
if trimmed := strings.TrimSpace(artist.Name); trimmed != "" {
names = append(names, trimmed)
}
}
if len(names) > 0 {
return strings.Join(names, ", ")
}
}
return strings.TrimSpace(track.Artist.Name)
}
func tidalAlbumArtistsDisplay(album *tidalPublicAlbum) string {
if album == nil {
return ""
}
return tidalJoinArtistNames(album.Artists)
}
func tidalTrackExternalURL(track *TidalTrack) string {
if track == nil {
return ""
}
if trimmed := strings.TrimSpace(track.URL); trimmed != "" {
return strings.Replace(trimmed, "http://", "https://", 1)
}
if track.ID > 0 {
return fmt.Sprintf("https://tidal.com/browse/track/%d", track.ID)
}
return ""
}
func tidalAlbumExternalURL(album *tidalPublicAlbum) string {
if album == nil {
return ""
}
if trimmed := strings.TrimSpace(album.URL); trimmed != "" {
return strings.Replace(trimmed, "http://", "https://", 1)
}
if album.ID > 0 {
return fmt.Sprintf("https://tidal.com/browse/album/%d", album.ID)
}
return ""
}
func tidalTrackToTrackMetadata(track *TidalTrack) TrackMetadata {
if track == nil {
return TrackMetadata{}
}
artistID := tidalPrefixedNumericID(track.Artist.ID)
if artistID == "" && len(track.Artists) > 0 {
artistID = tidalPrefixedNumericID(track.Artists[0].ID)
}
return TrackMetadata{
SpotifyID: tidalPrefixedNumericID(track.ID),
Artists: tidalTrackArtistsDisplay(track),
Name: strings.TrimSpace(track.Title),
AlbumName: strings.TrimSpace(track.Album.Title),
AlbumArtist: strings.TrimSpace(track.Artist.Name),
DurationMS: track.Duration * 1000,
Images: tidalImageURL(track.Album.Cover, "1280x1280"),
ReleaseDate: strings.TrimSpace(track.Album.ReleaseDate),
TrackNumber: track.TrackNumber,
DiscNumber: track.VolumeNumber,
ExternalURL: tidalTrackExternalURL(track),
ISRC: strings.TrimSpace(track.ISRC),
AlbumID: tidalPrefixedNumericID(track.Album.ID),
ArtistID: artistID,
}
}
func tidalTrackToAlbumTrackMetadata(track *TidalTrack) AlbumTrackMetadata {
if track == nil {
return AlbumTrackMetadata{}
}
return AlbumTrackMetadata{
SpotifyID: tidalPrefixedNumericID(track.ID),
Artists: tidalTrackArtistsDisplay(track),
Name: strings.TrimSpace(track.Title),
AlbumName: strings.TrimSpace(track.Album.Title),
AlbumArtist: strings.TrimSpace(track.Artist.Name),
DurationMS: track.Duration * 1000,
Images: tidalImageURL(track.Album.Cover, "1280x1280"),
ReleaseDate: strings.TrimSpace(track.Album.ReleaseDate),
TrackNumber: track.TrackNumber,
DiscNumber: track.VolumeNumber,
ExternalURL: tidalTrackExternalURL(track),
ISRC: strings.TrimSpace(track.ISRC),
AlbumID: tidalPrefixedNumericID(track.Album.ID),
AlbumURL: strings.Replace(strings.TrimSpace(track.Album.URL), "http://", "https://", 1),
}
}
func tidalAlbumToAlbumInfo(album *tidalPublicAlbum) AlbumInfoMetadata {
if album == nil {
return AlbumInfoMetadata{}
}
artistID := ""
if len(album.Artists) > 0 {
artistID = tidalPrefixedNumericID(album.Artists[0].ID)
}
return AlbumInfoMetadata{
TotalTracks: album.NumberOfTracks,
Name: strings.TrimSpace(album.Title),
ReleaseDate: strings.TrimSpace(album.ReleaseDate),
Artists: tidalAlbumArtistsDisplay(album),
ArtistId: artistID,
Images: tidalImageURL(album.Cover, "1280x1280"),
}
}
func tidalAlbumToArtistAlbum(album *tidalPublicAlbum) ArtistAlbumMetadata {
return tidalAlbumToArtistAlbumWithType(album, "")
}
func tidalAlbumToArtistAlbumWithType(album *tidalPublicAlbum, fallbackType string) ArtistAlbumMetadata {
if album == nil {
return ArtistAlbumMetadata{}
}
albumType := strings.ToLower(strings.TrimSpace(album.Type))
if albumType == "" {
albumType = strings.ToLower(strings.TrimSpace(fallbackType))
}
if albumType == "" {
albumType = "album"
}
return ArtistAlbumMetadata{
ID: tidalPrefixedNumericID(album.ID),
Name: strings.TrimSpace(album.Title),
ReleaseDate: strings.TrimSpace(album.ReleaseDate),
TotalTracks: album.NumberOfTracks,
Images: tidalImageURL(album.Cover, "1280x1280"),
AlbumType: albumType,
Artists: tidalAlbumArtistsDisplay(album),
}
}
func tidalPlaylistOwnerName(playlist *tidalPublicPlaylist) string {
if playlist == nil {
return ""
}
if trimmed := strings.TrimSpace(playlist.Creator.Name); trimmed != "" {
return trimmed
}
if strings.EqualFold(strings.TrimSpace(playlist.Type), "ARTIST") {
return "Artist"
}
return "TIDAL"
}
func tidalArtistAlbumTypeFromModuleTitle(title string) string {
normalized := strings.ToLower(strings.TrimSpace(title))
switch normalized {
case "albums", "compilations", "appears on":
return "album"
case "ep & singles", "eps & singles", "singles", "ep", "eps":
return "single"
default:
return ""
}
}
func tidalBuildMetadataURL(path string, extraQuery url.Values) string {
trimmedPath := strings.TrimLeft(strings.TrimSpace(path), "/")
if trimmedPath == "" {
return tidalPublicAPIBaseURL
}
baseURL, err := url.Parse(tidalPublicAPIBaseURL + "/" + trimmedPath)
if err != nil {
return tidalPublicAPIBaseURL + "/" + trimmedPath
}
query := baseURL.Query()
query.Set("countryCode", tidalCountryCode)
query.Set("locale", tidalLocale)
query.Set("deviceType", tidalDeviceType)
for key, values := range extraQuery {
query.Del(key)
for _, value := range values {
query.Add(key, value)
}
}
baseURL.RawQuery = query.Encode()
return baseURL.String()
}
func (t *TidalDownloader) getTidalMetadataJSON(requestURL string, target interface{}) error {
req, err := http.NewRequest("GET", requestURL, nil)
if err != nil {
return err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("x-tidal-token", tidalPublicToken)
resp, err := DoRequestWithUserAgent(t.client, req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return fmt.Errorf("tidal metadata request failed: HTTP %d (%s)", resp.StatusCode, strings.TrimSpace(string(body)))
}
return json.NewDecoder(resp.Body).Decode(target)
}
func (t *TidalDownloader) getPublicTrack(resourceID string) (*TidalTrack, error) {
trackID, err := strconv.ParseInt(strings.TrimSpace(resourceID), 10, 64)
if err != nil || trackID <= 0 {
return nil, fmt.Errorf("invalid tidal track ID: %s", resourceID)
}
requestURL := tidalBuildMetadataURL(fmt.Sprintf("tracks/%d", trackID), nil)
var track TidalTrack
if err := t.getTidalMetadataJSON(requestURL, &track); err != nil {
return nil, err
}
return &track, nil
}
func (t *TidalDownloader) getAlbumPage(resourceID string) (*tidalPublicAlbumPage, error) {
albumID := strings.TrimSpace(resourceID)
if albumID == "" {
return nil, fmt.Errorf("invalid tidal album ID")
}
requestURL := tidalBuildMetadataURL("pages/album", url.Values{"albumId": {albumID}})
var page tidalPublicAlbumPage
if err := t.getTidalMetadataJSON(requestURL, &page); err != nil {
return nil, err
}
return &page, nil
}
func (t *TidalDownloader) getArtistPage(resourceID string) (*tidalPublicArtistPage, error) {
artistID := strings.TrimSpace(resourceID)
if artistID == "" {
return nil, fmt.Errorf("invalid tidal artist ID")
}
requestURL := tidalBuildMetadataURL("pages/artist", url.Values{"artistId": {artistID}})
var page tidalPublicArtistPage
if err := t.getTidalMetadataJSON(requestURL, &page); err != nil {
return nil, err
}
return &page, nil
}
func (t *TidalDownloader) getArtistAlbumsPage(dataAPIPath string, offset, limit int) (*tidalPublicArtistAlbumsPage, error) {
extraQuery := url.Values{}
if offset >= 0 {
extraQuery.Set("offset", strconv.Itoa(offset))
}
if limit > 0 {
extraQuery.Set("limit", strconv.Itoa(limit))
}
requestURL := tidalBuildMetadataURL(dataAPIPath, extraQuery)
var page tidalPublicArtistAlbumsPage
if err := t.getTidalMetadataJSON(requestURL, &page); err != nil {
return nil, err
}
return &page, nil
}
func (t *TidalDownloader) getPlaylist(resourceID string) (*tidalPublicPlaylist, error) {
playlistID := strings.TrimSpace(resourceID)
if playlistID == "" {
return nil, fmt.Errorf("invalid tidal playlist ID")
}
requestURL := tidalBuildMetadataURL("playlists/"+url.PathEscape(playlistID), nil)
var playlist tidalPublicPlaylist
if err := t.getTidalMetadataJSON(requestURL, &playlist); err != nil {
return nil, err
}
return &playlist, nil
}
func (t *TidalDownloader) getPlaylistItemsPage(resourceID string, offset, limit int) (*tidalPublicPlaylistItemsPage, error) {
playlistID := strings.TrimSpace(resourceID)
if playlistID == "" {
return nil, fmt.Errorf("invalid tidal playlist ID")
}
requestURL := tidalBuildMetadataURL(
"playlists/"+url.PathEscape(playlistID)+"/items",
url.Values{
"offset": {strconv.Itoa(offset)},
"limit": {strconv.Itoa(limit)},
},
)
var page tidalPublicPlaylistItemsPage
if err := t.getTidalMetadataJSON(requestURL, &page); err != nil {
return nil, err
}
return &page, nil
}
func (t *TidalDownloader) getTrackSearchPage(query string, limit int) (*tidalPublicTrackSearchResponse, error) {
cleanQuery := strings.TrimSpace(query)
if cleanQuery == "" {
return nil, fmt.Errorf("empty tidal search query")
}
if limit <= 0 {
limit = 20
}
requestURL := tidalBuildMetadataURL(
"search/tracks",
url.Values{
"query": {cleanQuery},
"limit": {strconv.Itoa(limit)},
"offset": {"0"},
},
)
var page tidalPublicTrackSearchResponse
if err := t.getTidalMetadataJSON(requestURL, &page); err != nil {
return nil, err
}
return &page, nil
}
func findTidalAlbumPageModule(page *tidalPublicAlbumPage, moduleType string) *struct {
Type string `json:"type"`
Album tidalPublicAlbum `json:"album"`
PagedList struct {
DataAPIPath string `json:"dataApiPath"`
Limit int `json:"limit"`
Offset int `json:"offset"`
TotalNumberOfItems int `json:"totalNumberOfItems"`
Items []struct {
Item TidalTrack `json:"item"`
Type string `json:"type"`
} `json:"items"`
} `json:"pagedList"`
} {
if page == nil {
return nil
}
for rowIndex := range page.Rows {
for moduleIndex := range page.Rows[rowIndex].Modules {
module := &page.Rows[rowIndex].Modules[moduleIndex]
if module.Type == moduleType {
return module
}
}
}
return nil
}
func findTidalArtistPageModule(page *tidalPublicArtistPage, moduleType string) *struct {
Type string `json:"type"`
Title string `json:"title"`
Artist struct {
ID int64 `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
Picture string `json:"picture"`
} `json:"artist"`
PagedList struct {
DataAPIPath string `json:"dataApiPath"`
Limit int `json:"limit"`
Offset int `json:"offset"`
TotalNumberOfItems int `json:"totalNumberOfItems"`
Items []tidalPublicAlbum `json:"items"`
} `json:"pagedList"`
} {
if page == nil {
return nil
}
for rowIndex := range page.Rows {
for moduleIndex := range page.Rows[rowIndex].Modules {
module := &page.Rows[rowIndex].Modules[moduleIndex]
if module.Type == moduleType {
return module
}
}
}
return nil
}
func (t *TidalDownloader) GetAvailableAPIs() []string {
return []string{
"https://tidal-api.binimum.org",
@@ -203,6 +769,183 @@ func (t *TidalDownloader) SearchTrackByMetadata(trackName, artistName string) (*
return nil, fmt.Errorf("tidal metadata search API disabled: no client credentials mode")
}
func (t *TidalDownloader) SearchTracks(query string, limit int) ([]ExtTrackMetadata, error) {
page, err := t.getTrackSearchPage(query, limit)
if err != nil {
return nil, err
}
results := make([]ExtTrackMetadata, 0, len(page.Items))
for i := range page.Items {
results = append(results, normalizeBuiltInMetadataTrack(tidalTrackToTrackMetadata(&page.Items[i]), "tidal"))
}
return results, nil
}
func (t *TidalDownloader) GetTrackMetadata(resourceID string) (*TrackResponse, error) {
track, err := t.getPublicTrack(resourceID)
if err != nil {
return nil, err
}
return &TrackResponse{Track: tidalTrackToTrackMetadata(track)}, nil
}
func (t *TidalDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePayload, error) {
page, err := t.getAlbumPage(resourceID)
if err != nil {
return nil, err
}
headerModule := findTidalAlbumPageModule(page, "ALBUM_HEADER")
itemsModule := findTidalAlbumPageModule(page, "ALBUM_ITEMS")
if headerModule == nil {
return nil, fmt.Errorf("tidal album page missing album header")
}
if itemsModule == nil {
return nil, fmt.Errorf("tidal album page missing track list")
}
tracks := make([]AlbumTrackMetadata, 0, len(itemsModule.PagedList.Items))
for _, item := range itemsModule.PagedList.Items {
track := item.Item
if track.Album.ID == 0 {
track.Album.ID = headerModule.Album.ID
track.Album.Title = headerModule.Album.Title
track.Album.Cover = headerModule.Album.Cover
track.Album.ReleaseDate = headerModule.Album.ReleaseDate
track.Album.URL = headerModule.Album.URL
}
tracks = append(tracks, tidalTrackToAlbumTrackMetadata(&track))
}
return &AlbumResponsePayload{
AlbumInfo: tidalAlbumToAlbumInfo(&headerModule.Album),
TrackList: tracks,
}, nil
}
func (t *TidalDownloader) GetPlaylistMetadata(resourceID string) (*PlaylistResponsePayload, error) {
playlist, err := t.getPlaylist(resourceID)
if err != nil {
return nil, err
}
const pageSize = 50
offset := 0
totalTracks := playlist.NumberOfTracks
tracks := make([]AlbumTrackMetadata, 0, totalTracks)
for {
page, pageErr := t.getPlaylistItemsPage(resourceID, offset, pageSize)
if pageErr != nil {
return nil, pageErr
}
if totalTracks == 0 && page.TotalNumberOfItems > 0 {
totalTracks = page.TotalNumberOfItems
}
for _, item := range page.Items {
if item.Type != "track" {
continue
}
tracks = append(tracks, tidalTrackToAlbumTrackMetadata(&item.Item))
}
if len(page.Items) == 0 || offset+len(page.Items) >= totalTracks || len(page.Items) < pageSize {
break
}
offset += len(page.Items)
}
var info PlaylistInfoMetadata
info.Tracks.Total = totalTracks
info.Name = strings.TrimSpace(playlist.Title)
info.Images = tidalImageURL(tidalFirstNonEmpty(playlist.SquareImage, playlist.Image), "origin")
info.Owner.DisplayName = tidalPlaylistOwnerName(playlist)
info.Owner.Name = strings.TrimSpace(playlist.Title)
info.Owner.Images = info.Images
return &PlaylistResponsePayload{
PlaylistInfo: info,
TrackList: tracks,
}, nil
}
func (t *TidalDownloader) GetArtistMetadata(resourceID string) (*ArtistResponsePayload, error) {
page, err := t.getArtistPage(resourceID)
if err != nil {
return nil, err
}
headerModule := findTidalArtistPageModule(page, "ARTIST_HEADER")
albumsModule := findTidalArtistPageModule(page, "ALBUM_LIST")
if headerModule == nil {
return nil, fmt.Errorf("tidal artist page missing artist header")
}
if albumsModule == nil {
return nil, fmt.Errorf("tidal artist page missing albums list")
}
albums := make([]ArtistAlbumMetadata, 0, albumsModule.PagedList.TotalNumberOfItems)
seenAlbumIDs := make(map[string]struct{})
appendArtistAlbum := func(album tidalPublicAlbum, fallbackType string) {
mapped := tidalAlbumToArtistAlbumWithType(&album, fallbackType)
if mapped.ID == "" {
return
}
if _, exists := seenAlbumIDs[mapped.ID]; exists {
return
}
seenAlbumIDs[mapped.ID] = struct{}{}
albums = append(albums, mapped)
}
for rowIndex := range page.Rows {
for moduleIndex := range page.Rows[rowIndex].Modules {
module := &page.Rows[rowIndex].Modules[moduleIndex]
if module.Type != "ALBUM_LIST" {
continue
}
fallbackType := tidalArtistAlbumTypeFromModuleTitle(module.Title)
for _, album := range module.PagedList.Items {
appendArtistAlbum(album, fallbackType)
}
pageSize := module.PagedList.Limit
if pageSize <= 0 {
pageSize = 50
}
offset := len(module.PagedList.Items)
for offset < module.PagedList.TotalNumberOfItems && strings.TrimSpace(module.PagedList.DataAPIPath) != "" {
albumsPage, pageErr := t.getArtistAlbumsPage(module.PagedList.DataAPIPath, offset, pageSize)
if pageErr != nil {
return nil, pageErr
}
for _, album := range albumsPage.Items {
appendArtistAlbum(album, fallbackType)
}
if len(albumsPage.Items) == 0 || offset+len(albumsPage.Items) >= albumsPage.TotalNumberOfItems {
break
}
offset += len(albumsPage.Items)
}
}
}
return &ArtistResponsePayload{
ArtistInfo: ArtistInfoMetadata{
ID: tidalPrefixedNumericID(headerModule.Artist.ID),
Name: strings.TrimSpace(headerModule.Artist.Name),
Images: tidalImageURL(headerModule.Artist.Picture, "750x750"),
},
Albums: albums,
}, nil
}
type TidalDownloadInfo struct {
URL string
BitDepth int
@@ -583,7 +1326,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
GoLog("[Tidal] Manifest parsed - directURL: %v, initURL: %v, mediaURLs count: %d\n",
directURL != "", initURL != "", len(mediaURLs))
client := NewHTTPClientWithTimeout(120 * time.Second)
client := NewHTTPClientWithTimeout(DownloadTimeout)
if directURL != "" {
GoLog("[Tidal] BTS format - downloading from direct URL: %s...\n", directURL[:min(80, len(directURL))])
@@ -1062,20 +1805,18 @@ func isLatinScript(s string) bool {
return true
}
func tidalTrackArtistsDisplay(track *TidalTrack) string {
if track == nil {
return ""
func parseTidalRequestTrackID(raw string) (int64, bool) {
trimmed := strings.TrimSpace(raw)
trimmed = strings.TrimPrefix(trimmed, "tidal:")
if trimmed == "" {
return 0, false
}
tidalArtist := track.Artist.Name
if len(track.Artists) > 0 {
var artistNames []string
for _, a := range track.Artists {
artistNames = append(artistNames, a.Name)
}
tidalArtist = strings.Join(artistNames, ", ")
trackID, err := strconv.ParseInt(trimmed, 10, 64)
if err != nil || trackID <= 0 {
return 0, false
}
return tidalArtist
return trackID, true
}
func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloader, logPrefix string) (*TidalTrack, error) {
@@ -1091,8 +1832,9 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
var gotTidalID bool
if req.TidalID != "" {
GoLog("[%s] Using Tidal ID from Odesli enrichment: %s\n", logPrefix, req.TidalID)
if _, parseErr := fmt.Sscanf(req.TidalID, "%d", &trackID); parseErr == nil && trackID > 0 {
GoLog("[%s] Using Tidal ID from request payload: %s\n", logPrefix, req.TidalID)
if parsedTrackID, ok := parseTidalRequestTrackID(req.TidalID); ok {
trackID = parsedTrackID
gotTidalID = true
}
}
@@ -1113,7 +1855,8 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
return
}
if availability.TidalID != "" {
if _, parseErr := fmt.Sscanf(availability.TidalID, "%d", &trackID); parseErr == nil && trackID > 0 {
if parsedTrackID, ok := parseTidalRequestTrackID(availability.TidalID); ok {
trackID = parsedTrackID
GoLog("[%s] Got Tidal ID %d directly from SongLink\n", logPrefix, trackID)
gotTidalID = true
return
@@ -1168,6 +1911,32 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
return nil, fmt.Errorf("failed to find tidal track id from request/cache/songlink")
}
// Verify the resolved track matches the request.
actualTrack, fetchErr := downloader.getPublicTrack(strconv.FormatInt(trackID, 10))
if fetchErr != nil {
GoLog("[%s] Warning: could not fetch Tidal track %d for verification: %v\n", logPrefix, trackID, fetchErr)
// Continue without verification — better than failing entirely.
} else {
providerArtist := actualTrack.Artist.Name
if providerArtist == "" && len(actualTrack.Artists) > 0 {
providerArtist = actualTrack.Artists[0].Name
}
resolved := resolvedTrackInfo{
Title: actualTrack.Title,
ArtistName: providerArtist,
Duration: actualTrack.Duration,
}
if !trackMatchesRequest(req, resolved, logPrefix) {
// Invalidate the cached ID so future requests don't reuse it.
if req.ISRC != "" {
GetTrackIDCache().SetTidal(req.ISRC, 0)
}
return nil, fmt.Errorf("tidal track %d does not match request: expected '%s - %s', got '%s - %s'",
trackID, req.ArtistName, req.TrackName, resolved.ArtistName, resolved.Title)
}
GoLog("[%s] Track %d verified: '%s - %s' ✓\n", logPrefix, trackID, resolved.ArtistName, resolved.Title)
}
track := &TidalTrack{
ID: trackID,
Title: strings.TrimSpace(req.TrackName),
@@ -1218,11 +1987,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
outputExt := strings.TrimSpace(req.OutputExt)
if outputExt == "" {
if quality == "HIGH" {
outputExt = ".m4a"
} else {
outputExt = ".flac"
}
outputExt = ".flac"
} else if !strings.HasPrefix(outputExt, ".") {
outputExt = "." + outputExt
}
@@ -1236,7 +2001,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
}
m4aPath = outputPath
} else {
if outputExt == ".m4a" || quality == "HIGH" {
if outputExt == ".m4a" {
filename = sanitizeFilename(filename) + ".m4a"
outputPath = filepath.Join(req.OutputDir, filename)
m4aPath = outputPath
@@ -1249,10 +2014,8 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
return TidalDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
}
if quality != "HIGH" {
if fileInfo, statErr := os.Stat(m4aPath); statErr == nil && fileInfo.Size() > 0 {
return TidalDownloadResult{FilePath: "EXISTS:" + m4aPath}, nil
}
if fileInfo, statErr := os.Stat(m4aPath); statErr == nil && fileInfo.Size() > 0 {
return TidalDownloadResult{FilePath: "EXISTS:" + m4aPath}, nil
}
}
@@ -1408,27 +2171,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
fmt.Println("[Tidal] No lyrics available from parallel fetch")
}
} else if (isSafOutput && actualExt == ".m4a") || (!isSafOutput && strings.HasSuffix(actualOutputPath, ".m4a")) {
if quality == "HIGH" {
GoLog("[Tidal] HIGH quality M4A - skipping metadata embedding (file from server is already valid)\n")
if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
lyricsMode := req.LyricsMode
if lyricsMode == "" {
lyricsMode = "embed"
}
if !isSafOutput && (lyricsMode == "external" || lyricsMode == "both") {
GoLog("[Tidal] Saving external LRC file for M4A (mode: %s)...\n", lyricsMode)
if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil {
GoLog("[Tidal] Warning: failed to save LRC file: %v\n", lrcErr)
} else {
GoLog("[Tidal] LRC file saved: %s\n", lrcPath)
}
}
}
} else {
fmt.Println("[Tidal] Skipping metadata embedding for M4A file (will be handled after FFmpeg conversion)")
}
fmt.Println("[Tidal] Skipping metadata embedding for M4A file (will be handled after FFmpeg conversion)")
}
if !isSafOutput {
@@ -1438,24 +2181,28 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
bitDepth := downloadInfo.BitDepth
sampleRate := downloadInfo.SampleRate
lyricsLRC := ""
if quality == "HIGH" {
bitDepth = 0
sampleRate = 44100
}
if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
lyricsLRC = parallelResult.LyricsLRC
}
resultAlbum, resultReleaseDate, resultTrackNumber, resultDiscNumber := preferredReleaseMetadata(
req,
track.Album.Title,
track.Album.ReleaseDate,
actualTrackNumber,
actualDiscNumber,
)
return TidalDownloadResult{
FilePath: actualOutputPath,
BitDepth: bitDepth,
SampleRate: sampleRate,
Title: track.Title,
Artist: track.Artist.Name,
Album: track.Album.Title,
ReleaseDate: track.Album.ReleaseDate,
TrackNumber: actualTrackNumber,
DiscNumber: actualDiscNumber,
Album: resultAlbum,
ReleaseDate: resultReleaseDate,
TrackNumber: resultTrackNumber,
DiscNumber: resultDiscNumber,
ISRC: track.ISRC,
LyricsLRC: lyricsLRC,
}, nil
+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)
}
}
+42
View File
@@ -68,3 +68,45 @@ func normalizeSymbolOnlyTitle(title string) string {
return b.String()
}
// ==================== Shared Track Verification ====================
// resolvedTrackInfo holds the metadata fetched from a provider for verification.
type resolvedTrackInfo struct {
Title string
ArtistName string
Duration int // seconds
}
// trackMatchesRequest checks whether a resolved track from a provider matches
// the original download request. Returns true if the track is a plausible match.
func trackMatchesRequest(req DownloadRequest, resolved resolvedTrackInfo, logPrefix string) bool {
if req.ArtistName != "" && resolved.ArtistName != "" &&
!artistsMatch(req.ArtistName, resolved.ArtistName) {
GoLog("[%s] Verification failed: artist mismatch — expected '%s', got '%s'\n",
logPrefix, req.ArtistName, resolved.ArtistName)
return false
}
if req.TrackName != "" && resolved.Title != "" &&
!titlesMatch(req.TrackName, resolved.Title) {
GoLog("[%s] Verification failed: title mismatch — expected '%s', got '%s'\n",
logPrefix, req.TrackName, resolved.Title)
return false
}
expectedDurationSec := req.DurationMS / 1000
if expectedDurationSec > 0 && resolved.Duration > 0 {
diff := expectedDurationSec - resolved.Duration
if diff < 0 {
diff = -diff
}
if diff > 10 {
GoLog("[%s] Verification failed: duration mismatch — expected %ds, got %ds\n",
logPrefix, expectedDurationSec, resolved.Duration)
return false
}
}
return true
}
+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)
}
}
+54 -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
@@ -415,6 +383,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 +406,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 +501,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 +623,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 +828,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
+7 -2
View File
@@ -1,9 +1,14 @@
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.8.6';
static const String buildNumber = '112';
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';
+797 -2
View File
@@ -256,7 +256,7 @@ abstract class AppLocalizations {
/// **'Filename Format'**
String get downloadFilenameFormat;
/// Setting for folder structure
/// Title of the folder organization picker bottom sheet
///
/// In en, this message translates to:
/// **'Folder Organization'**
@@ -1066,6 +1066,12 @@ abstract class AppLocalizations {
/// **'Import'**
String get dialogImport;
/// Confirm button in Download All dialog
///
/// In en, this message translates to:
/// **'Download'**
String get dialogDownload;
/// Dialog button - discard changes
///
/// In en, this message translates to:
@@ -2236,6 +2242,84 @@ abstract class AppLocalizations {
/// **'Clear filters'**
String get storeClearFilters;
/// Store setup screen - heading when no repo is configured
///
/// In en, this message translates to:
/// **'Add Extension Repository'**
String get storeAddRepoTitle;
/// Store setup screen - explanatory text
///
/// In en, this message translates to:
/// **'Enter a GitHub repository URL that contains a registry.json file to browse and install extensions.'**
String get storeAddRepoDescription;
/// Label for the repository URL input field
///
/// In en, this message translates to:
/// **'Repository URL'**
String get storeRepoUrlLabel;
/// Hint/placeholder for the repository URL input field
///
/// In en, this message translates to:
/// **'https://github.com/user/repo'**
String get storeRepoUrlHint;
/// Helper text below the repository URL input field
///
/// In en, this message translates to:
/// **'e.g. https://github.com/user/extensions-repo'**
String get storeRepoUrlHelper;
/// Button to submit a new repository URL
///
/// In en, this message translates to:
/// **'Add Repository'**
String get storeAddRepoButton;
/// Tooltip for the change-repository icon button in the app bar
///
/// In en, this message translates to:
/// **'Change repository'**
String get storeChangeRepoTooltip;
/// Title of the change/remove repository dialog
///
/// In en, this message translates to:
/// **'Extension Repository'**
String get storeRepoDialogTitle;
/// Label shown above the current repository URL in the dialog
///
/// In en, this message translates to:
/// **'Current repository:'**
String get storeRepoDialogCurrent;
/// Label for the new repository URL field inside the dialog
///
/// In en, this message translates to:
/// **'New Repository URL'**
String get storeNewRepoUrlLabel;
/// Error heading when the store cannot be loaded
///
/// In en, this message translates to:
/// **'Failed to load store'**
String get storeLoadError;
/// Message when store has no extensions
///
/// In en, this message translates to:
/// **'No extensions available'**
String get storeEmptyNoExtensions;
/// Message when search/filter returns no results
///
/// In en, this message translates to:
/// **'No extensions found'**
String get storeEmptyNoResults;
/// Default search provider option
///
/// In en, this message translates to:
@@ -3022,6 +3106,42 @@ abstract class AppLocalizations {
/// **'Show when searching for existing tracks'**
String get libraryShowDuplicateIndicatorSubtitle;
/// Setting for automatic library scanning
///
/// In en, this message translates to:
/// **'Auto Scan'**
String get libraryAutoScan;
/// Subtitle for auto scan setting
///
/// In en, this message translates to:
/// **'Automatically scan your library for new files'**
String get libraryAutoScanSubtitle;
/// Auto scan disabled
///
/// In en, this message translates to:
/// **'Off'**
String get libraryAutoScanOff;
/// Auto scan when app opens
///
/// In en, this message translates to:
/// **'Every app open'**
String get libraryAutoScanOnOpen;
/// Auto scan once per day
///
/// In en, this message translates to:
/// **'Daily'**
String get libraryAutoScanDaily;
/// Auto scan once per week
///
/// In en, this message translates to:
/// **'Weekly'**
String get libraryAutoScanWeekly;
/// Section header for library actions
///
/// In en, this message translates to:
@@ -3754,6 +3874,36 @@ abstract class AppLocalizations {
/// **'FFmpeg metadata embed failed'**
String get trackReEnrichFfmpegFailed;
/// Action/button label for queueing FLAC redownloads for local tracks
///
/// In en, this message translates to:
/// **'Queue FLAC'**
String get queueFlacAction;
/// Confirmation dialog body before queueing FLAC redownloads for local tracks
///
/// In en, this message translates to:
/// **'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n{count} selected'**
String queueFlacConfirmMessage(int count);
/// Snackbar while resolving remote matches for local FLAC redownloads
///
/// In en, this message translates to:
/// **'Finding FLAC matches... ({current}/{total})'**
String queueFlacFindingProgress(int current, int total);
/// Snackbar when no safe FLAC redownload matches were found
///
/// In en, this message translates to:
/// **'No reliable online matches found for the selection'**
String get queueFlacNoReliableMatches;
/// Snackbar when some selected local tracks were queued for FLAC redownload and some were skipped
///
/// In en, this message translates to:
/// **'Added {addedCount} tracks to queue, skipped {skippedCount}'**
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount);
/// Snackbar when save operation fails
///
/// In en, this message translates to:
@@ -3769,7 +3919,7 @@ abstract class AppLocalizations {
/// Subtitle for convert format menu item
///
/// In en, this message translates to:
/// **'Convert to MP3 or Opus'**
/// **'Convert to MP3, Opus, ALAC, or FLAC'**
String get trackConvertFormatSubtitle;
/// Title of convert bottom sheet
@@ -3806,6 +3956,21 @@ abstract class AppLocalizations {
String bitrate,
);
/// Confirmation dialog message for lossless-to-lossless conversion
///
/// In en, this message translates to:
/// **'Convert from {sourceFormat} to {targetFormat}? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.'**
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
);
/// Hint shown when converting between lossless formats
///
/// In en, this message translates to:
/// **'Lossless conversion — no quality loss'**
String get trackConvertLosslessHint;
/// Snackbar while converting
///
/// In en, this message translates to:
@@ -4176,6 +4341,12 @@ abstract class AppLocalizations {
String bitrate,
);
/// Confirmation dialog message for lossless batch conversion
///
/// In en, this message translates to:
/// **'Convert {count} {count, plural, =1{track} other{tracks}} to {format}? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.'**
String selectionBatchConvertConfirmMessageLossless(int count, String format);
/// Snackbar during batch conversion progress
///
/// In en, this message translates to:
@@ -4205,6 +4376,630 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Artist folders use Track Artist only'**
String get downloadUseAlbumArtistForFoldersTrackSubtitle;
/// Title for the lyrics provider priority page
///
/// In en, this message translates to:
/// **'Lyrics Providers'**
String get lyricsProvidersTitle;
/// Description on the lyrics provider priority page
///
/// In en, this message translates to:
/// **'Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.'**
String get lyricsProvidersDescription;
/// Info tip on lyrics provider priority page
///
/// In en, this message translates to:
/// **'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.'**
String get lyricsProvidersInfoText;
/// Section header for enabled providers
///
/// In en, this message translates to:
/// **'Enabled ({count})'**
String lyricsProvidersEnabledSection(int count);
/// Section header for disabled providers
///
/// In en, this message translates to:
/// **'Disabled ({count})'**
String lyricsProvidersDisabledSection(int count);
/// Snackbar when user tries to disable the last enabled provider
///
/// In en, this message translates to:
/// **'At least one provider must remain enabled'**
String get lyricsProvidersAtLeastOne;
/// Snackbar after saving lyrics provider priority
///
/// In en, this message translates to:
/// **'Lyrics provider priority saved'**
String get lyricsProvidersSaved;
/// Body text of the discard-changes dialog on lyrics provider page
///
/// In en, this message translates to:
/// **'You have unsaved changes that will be lost.'**
String get lyricsProvidersDiscardContent;
/// Description for Spotify Lyrics API provider
///
/// In en, this message translates to:
/// **'Spotify-sourced synced lyrics via community API'**
String get lyricsProviderSpotifyApiDesc;
/// Description for LRCLIB provider
///
/// In en, this message translates to:
/// **'Open-source synced lyrics database'**
String get lyricsProviderLrclibDesc;
/// Description for Netease provider
///
/// In en, this message translates to:
/// **'NetEase Cloud Music (good for Asian songs)'**
String get lyricsProviderNeteaseDesc;
/// Description for Musixmatch provider
///
/// In en, this message translates to:
/// **'Largest lyrics database (multi-language)'**
String get lyricsProviderMusixmatchDesc;
/// Description for Apple Music provider
///
/// In en, this message translates to:
/// **'Word-by-word synced lyrics (via proxy)'**
String get lyricsProviderAppleMusicDesc;
/// Description for QQ Music provider
///
/// In en, this message translates to:
/// **'QQ Music (good for Chinese songs, via proxy)'**
String get lyricsProviderQqMusicDesc;
/// Generic description for extension-based lyrics providers
///
/// In en, this message translates to:
/// **'Extension provider'**
String get lyricsProviderExtensionDesc;
/// Title of SAF migration dialog
///
/// In en, this message translates to:
/// **'Storage Update Required'**
String get safMigrationTitle;
/// First paragraph of SAF migration dialog
///
/// In en, this message translates to:
/// **'SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. This fixes \"permission denied\" errors on Android 10+.'**
String get safMigrationMessage1;
/// Second paragraph of SAF migration dialog
///
/// In en, this message translates to:
/// **'Please select your download folder again to switch to the new storage system.'**
String get safMigrationMessage2;
/// Snackbar after successfully migrating to SAF
///
/// In en, this message translates to:
/// **'Download folder updated to SAF mode'**
String get safMigrationSuccess;
/// Settings menu item - donate
///
/// In en, this message translates to:
/// **'Donate'**
String get settingsDonate;
/// Subtitle for donate menu item
///
/// In en, this message translates to:
/// **'Support SpotiFLAC-Mobile development'**
String get settingsDonateSubtitle;
/// Tooltip for the Love All button on album/playlist screens
///
/// In en, this message translates to:
/// **'Love All'**
String get tooltipLoveAll;
/// Tooltip for the Add to Playlist button
///
/// In en, this message translates to:
/// **'Add to Playlist'**
String get tooltipAddToPlaylist;
/// Snackbar after removing multiple tracks from Loved folder
///
/// In en, this message translates to:
/// **'Removed {count} tracks from Loved'**
String snackbarRemovedTracksFromLoved(int count);
/// Snackbar after adding multiple tracks to Loved folder
///
/// In en, this message translates to:
/// **'Added {count} tracks to Loved'**
String snackbarAddedTracksToLoved(int count);
/// Dialog title for bulk download confirmation
///
/// In en, this message translates to:
/// **'Download All'**
String get dialogDownloadAllTitle;
/// Body of the Download All confirmation dialog
///
/// In en, this message translates to:
/// **'Download {count} tracks?'**
String dialogDownloadAllMessage(int count);
/// Checkbox label in import dialog to skip already-downloaded songs
///
/// In en, this message translates to:
/// **'Skip already downloaded songs'**
String get homeSkipAlreadyDownloaded;
/// Context menu item to navigate to the album page
///
/// In en, this message translates to:
/// **'Go to Album'**
String get homeGoToAlbum;
/// Snackbar when album info cannot be loaded
///
/// In en, this message translates to:
/// **'Album info not available'**
String get homeAlbumInfoUnavailable;
/// Snackbar while loading a CUE sheet file
///
/// In en, this message translates to:
/// **'Loading CUE sheet...'**
String get snackbarLoadingCueSheet;
/// Snackbar after successfully saving track metadata
///
/// In en, this message translates to:
/// **'Metadata saved successfully'**
String get snackbarMetadataSaved;
/// Snackbar when lyrics embedding fails
///
/// In en, this message translates to:
/// **'Failed to embed lyrics'**
String get snackbarFailedToEmbedLyrics;
/// Snackbar when writing metadata back to file fails
///
/// In en, this message translates to:
/// **'Failed to write back to storage'**
String get snackbarFailedToWriteStorage;
/// Generic error snackbar with error detail
///
/// In en, this message translates to:
/// **'Error: {error}'**
String snackbarError(String error);
/// Snackbar when an extension button has no action configured
///
/// In en, this message translates to:
/// **'No action defined for this button'**
String get snackbarNoActionDefined;
/// Empty state message when an album has no tracks
///
/// In en, this message translates to:
/// **'No tracks found for this album'**
String get noTracksFoundForAlbum;
/// Subtitle text in Android download location bottom sheet
///
/// In en, this message translates to:
/// **'Choose storage mode for downloaded files.'**
String get downloadLocationSubtitle;
/// Storage mode option - use legacy app folder
///
/// In en, this message translates to:
/// **'App folder (non-SAF)'**
String get storageModeAppFolder;
/// Subtitle for app folder storage mode
///
/// In en, this message translates to:
/// **'Use default Music/SpotiFLAC path'**
String get storageModeAppFolderSubtitle;
/// Storage mode option - use Android SAF picker
///
/// In en, this message translates to:
/// **'SAF folder'**
String get storageModeSaf;
/// Subtitle for SAF storage mode
///
/// In en, this message translates to:
/// **'Pick folder via Android Storage Access Framework'**
String get storageModeSafSubtitle;
/// Description text in filename format bottom sheet
///
/// In en, this message translates to:
/// **'Customize how your files are named.'**
String get downloadFilenameDescription;
/// Label above filename tag chips
///
/// In en, this message translates to:
/// **'Tap to insert tag:'**
String get downloadFilenameInsertTag;
/// Subtitle when separate singles folder is enabled
///
/// In en, this message translates to:
/// **'Albums/ and Singles/ folders'**
String get downloadSeparateSinglesEnabled;
/// Subtitle when separate singles folder is disabled
///
/// In en, this message translates to:
/// **'All files in same structure'**
String get downloadSeparateSinglesDisabled;
/// Setting title for artist folder filter options
///
/// In en, this message translates to:
/// **'Artist Name Filters'**
String get downloadArtistNameFilters;
/// Setting title for SongLink country region
///
/// In en, this message translates to:
/// **'SongLink Region'**
String get downloadSongLinkRegion;
/// Setting title for network compatibility toggle
///
/// In en, this message translates to:
/// **'Network compatibility mode'**
String get downloadNetworkCompatibilityMode;
/// Subtitle when network compatibility mode is enabled
///
/// In en, this message translates to:
/// **'Enabled: try HTTP + accept invalid TLS certificates (unsafe)'**
String get downloadNetworkCompatibilityModeEnabled;
/// Subtitle when network compatibility mode is disabled
///
/// In en, this message translates to:
/// **'Off: strict HTTPS certificate validation (recommended)'**
String get downloadNetworkCompatibilityModeDisabled;
/// Hint shown instead of Ask-quality subtitle when no built-in service selected
///
/// In en, this message translates to:
/// **'Select a built-in service to enable'**
String get downloadSelectServiceToEnable;
/// Info hint when non-Tidal/Qobuz service is selected
///
/// In en, this message translates to:
/// **'Select Tidal or Qobuz above to configure quality'**
String get downloadSelectTidalQobuz;
/// Subtitle for Embed Lyrics when Embed Metadata is disabled
///
/// In en, this message translates to:
/// **'Disabled while Embed Metadata is turned off'**
String get downloadEmbedLyricsDisabled;
/// Toggle title for including Netease translated lyrics
///
/// In en, this message translates to:
/// **'Netease: Include Translation'**
String get downloadNeteaseIncludeTranslation;
/// Subtitle when Netease translation is enabled
///
/// In en, this message translates to:
/// **'Append translated lyrics when available'**
String get downloadNeteaseIncludeTranslationEnabled;
/// Subtitle when Netease translation is disabled
///
/// In en, this message translates to:
/// **'Use original lyrics only'**
String get downloadNeteaseIncludeTranslationDisabled;
/// Toggle title for including Netease romanized lyrics
///
/// In en, this message translates to:
/// **'Netease: Include Romanization'**
String get downloadNeteaseIncludeRomanization;
/// Subtitle when Netease romanization is enabled
///
/// In en, this message translates to:
/// **'Append romanized lyrics when available'**
String get downloadNeteaseIncludeRomanizationEnabled;
/// Subtitle when Netease romanization is disabled
///
/// In en, this message translates to:
/// **'Disabled'**
String get downloadNeteaseIncludeRomanizationDisabled;
/// Toggle title for Apple/QQ multi-person word-by-word lyrics
///
/// In en, this message translates to:
/// **'Apple/QQ Multi-Person Word-by-Word'**
String get downloadAppleQqMultiPerson;
/// Subtitle when multi-person word-by-word is enabled
///
/// In en, this message translates to:
/// **'Enable v1/v2 speaker and [bg:] tags'**
String get downloadAppleQqMultiPersonEnabled;
/// Subtitle when multi-person word-by-word is disabled
///
/// In en, this message translates to:
/// **'Simplified word-by-word formatting'**
String get downloadAppleQqMultiPersonDisabled;
/// Setting title for Musixmatch language preference
///
/// In en, this message translates to:
/// **'Musixmatch Language'**
String get downloadMusixmatchLanguage;
/// Option label when Musixmatch uses original language
///
/// In en, this message translates to:
/// **'Auto (original)'**
String get downloadMusixmatchLanguageAuto;
/// Toggle title for filtering contributing artists in Album Artist metadata
///
/// In en, this message translates to:
/// **'Filter contributing artists in Album Artist'**
String get downloadFilterContributing;
/// Subtitle when contributing artist filter is enabled
///
/// In en, this message translates to:
/// **'Album Artist metadata uses primary artist only'**
String get downloadFilterContributingEnabled;
/// Subtitle when contributing artist filter is disabled
///
/// In en, this message translates to:
/// **'Keep full Album Artist metadata value'**
String get downloadFilterContributingDisabled;
/// Subtitle for lyrics providers setting when no providers are enabled
///
/// In en, this message translates to:
/// **'None enabled'**
String get downloadProvidersNoneEnabled;
/// Label for the Musixmatch language code text field
///
/// In en, this message translates to:
/// **'Language code'**
String get downloadMusixmatchLanguageCode;
/// Hint text for the Musixmatch language code field
///
/// In en, this message translates to:
/// **'auto / en / es / ja'**
String get downloadMusixmatchLanguageHint;
/// Description in the Musixmatch language picker
///
/// In en, this message translates to:
/// **'Set preferred language code (example: en, es, ja). Leave empty for auto.'**
String get downloadMusixmatchLanguageDesc;
/// Button to reset Musixmatch language to automatic
///
/// In en, this message translates to:
/// **'Auto'**
String get downloadMusixmatchAuto;
/// Subtitle for 'Any' network mode option
///
/// In en, this message translates to:
/// **'WiFi + Mobile Data'**
String get downloadNetworkAnySubtitle;
/// Subtitle for 'WiFi only' network mode option
///
/// In en, this message translates to:
/// **'Pause downloads on mobile data'**
String get downloadNetworkWifiOnlySubtitle;
/// Description in the SongLink region picker
///
/// In en, this message translates to:
/// **'Used as userCountry for SongLink API lookup.'**
String get downloadSongLinkRegionDesc;
/// Snackbar when the audio format is not supported for the requested operation
///
/// In en, this message translates to:
/// **'Unsupported audio format'**
String get snackbarUnsupportedAudioFormat;
/// Tooltip for refresh button on cache management page
///
/// In en, this message translates to:
/// **'Refresh'**
String get cacheRefresh;
/// Dialog message for bulk playlist download confirmation
///
/// In en, this message translates to:
/// **'Download {trackCount} {trackCount, plural, =1{track} other{tracks}} from {playlistCount} {playlistCount, plural, =1{playlist} other{playlists}}?'**
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount);
/// Button label for bulk downloading selected playlists
///
/// In en, this message translates to:
/// **'Download {count} {count, plural, =1{playlist} other{playlists}}'**
String bulkDownloadPlaylistsButton(int count);
/// Button label when no playlists are selected for download
///
/// In en, this message translates to:
/// **'Select playlists to download'**
String get bulkDownloadSelectPlaylists;
/// Snackbar when selected playlists contain no tracks
///
/// In en, this message translates to:
/// **'Selected playlists have no tracks'**
String get snackbarSelectedPlaylistsEmpty;
/// Playlist count display
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 playlist} other{{count} playlists}}'**
String playlistsCount(int count);
/// Section title for selective online metadata auto-fill in the edit metadata sheet
///
/// In en, this message translates to:
/// **'Auto-fill from online'**
String get editMetadataAutoFill;
/// Description for the auto-fill section
///
/// In en, this message translates to:
/// **'Select fields to fill automatically from online metadata'**
String get editMetadataAutoFillDesc;
/// Button label to fetch online metadata and fill selected fields
///
/// In en, this message translates to:
/// **'Fetch & Fill'**
String get editMetadataAutoFillFetch;
/// Snackbar shown while searching for online metadata
///
/// In en, this message translates to:
/// **'Searching online...'**
String get editMetadataAutoFillSearching;
/// Snackbar when online metadata search returns no results
///
/// In en, this message translates to:
/// **'No matching metadata found online'**
String get editMetadataAutoFillNoResults;
/// Snackbar confirming how many fields were auto-filled
///
/// In en, this message translates to:
/// **'Filled {count} {count, plural, =1{field} other{fields}} from online metadata'**
String editMetadataAutoFillDone(int count);
/// Snackbar when user taps Fetch without selecting any fields
///
/// In en, this message translates to:
/// **'Select at least one field to auto-fill'**
String get editMetadataAutoFillNoneSelected;
/// Chip label for title field in auto-fill selector
///
/// In en, this message translates to:
/// **'Title'**
String get editMetadataFieldTitle;
/// Chip label for artist field in auto-fill selector
///
/// In en, this message translates to:
/// **'Artist'**
String get editMetadataFieldArtist;
/// Chip label for album field in auto-fill selector
///
/// In en, this message translates to:
/// **'Album'**
String get editMetadataFieldAlbum;
/// Chip label for album artist field in auto-fill selector
///
/// In en, this message translates to:
/// **'Album Artist'**
String get editMetadataFieldAlbumArtist;
/// Chip label for date field in auto-fill selector
///
/// In en, this message translates to:
/// **'Date'**
String get editMetadataFieldDate;
/// Chip label for track number field in auto-fill selector
///
/// In en, this message translates to:
/// **'Track #'**
String get editMetadataFieldTrackNum;
/// Chip label for disc number field in auto-fill selector
///
/// In en, this message translates to:
/// **'Disc #'**
String get editMetadataFieldDiscNum;
/// Chip label for genre field in auto-fill selector
///
/// In en, this message translates to:
/// **'Genre'**
String get editMetadataFieldGenre;
/// Chip label for ISRC field in auto-fill selector
///
/// In en, this message translates to:
/// **'ISRC'**
String get editMetadataFieldIsrc;
/// Chip label for label field in auto-fill selector
///
/// In en, this message translates to:
/// **'Label'**
String get editMetadataFieldLabel;
/// Chip label for copyright field in auto-fill selector
///
/// In en, this message translates to:
/// **'Copyright'**
String get editMetadataFieldCopyright;
/// Chip label for cover art field in auto-fill selector
///
/// In en, this message translates to:
/// **'Cover Art'**
String get editMetadataFieldCover;
/// Button to select all fields for auto-fill
///
/// In en, this message translates to:
/// **'All'**
String get editMetadataSelectAll;
/// Button to select only fields that are currently empty
///
/// In en, this message translates to:
/// **'Empty only'**
String get editMetadataSelectEmpty;
}
class _AppLocalizationsDelegate
+506
View File
@@ -536,6 +536,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get dialogImport => 'Importieren';
@override
String get dialogDownload => 'Download';
@override
String get dialogDiscard => 'Verwerfen';
@@ -1215,6 +1218,47 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get storeClearFilters => 'Filter entfernen';
@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 => 'Standard (Deezer/Spotify)';
@@ -1675,6 +1719,25 @@ class AppLocalizationsDe extends AppLocalizations {
String get libraryShowDuplicateIndicatorSubtitle =>
'Bei der Suche nach vorhandenen Titeln anzeigen';
@override
String get libraryAutoScan => 'Auto Scan';
@override
String get libraryAutoScanSubtitle =>
'Automatically scan your library for new files';
@override
String get libraryAutoScanOff => 'Off';
@override
String get libraryAutoScanOnOpen => 'Every app open';
@override
String get libraryAutoScanDaily => 'Daily';
@override
String get libraryAutoScanWeekly => 'Weekly';
@override
String get libraryActions => 'Aktionen';
@@ -2128,6 +2191,28 @@ class AppLocalizationsDe extends AppLocalizations {
String get trackReEnrichFfmpegFailed =>
'FFmpeg Metadaten-Einbettung fehlgeschlagen';
@override
String get queueFlacAction => 'Queue FLAC';
@override
String queueFlacConfirmMessage(int count) {
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
}
@override
String queueFlacFindingProgress(int current, int total) {
return 'Finding FLAC matches... ($current/$total)';
}
@override
String get queueFlacNoReliableMatches =>
'No reliable online matches found for the selection';
@override
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
return 'Added $addedCount tracks to queue, skipped $skippedCount';
}
@override
String trackSaveFailed(String error) {
return 'Fehler: $error';
@@ -2160,6 +2245,18 @@ class AppLocalizationsDe extends AppLocalizations {
return 'Konvertieren von $sourceFormat in $targetFormat bei $bitrate?\n\nDie Originaldatei wird nach der Konvertierung gelöscht.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertLosslessHint =>
'Lossless conversion — no quality loss';
@override
String get trackConvertConverting => 'Konvertiere Audio...';
@@ -2414,6 +2511,17 @@ class AppLocalizationsDe extends AppLocalizations {
return 'Konvertiere $count $format $_temp0 zu $bitrate?\n\nOriginaldateien werden nach der Konvertierung gelöscht.';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Konvertiere $current von $total...';
@@ -2436,4 +2544,402 @@ class AppLocalizationsDe 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 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';
}
+508 -1
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';
@@ -1195,6 +1198,47 @@ class AppLocalizationsEn 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)';
@@ -1651,6 +1695,25 @@ class AppLocalizationsEn extends AppLocalizations {
String get libraryShowDuplicateIndicatorSubtitle =>
'Show when searching for existing tracks';
@override
String get libraryAutoScan => 'Auto Scan';
@override
String get libraryAutoScanSubtitle =>
'Automatically scan your library for new files';
@override
String get libraryAutoScanOff => 'Off';
@override
String get libraryAutoScanOnOpen => 'Every app open';
@override
String get libraryAutoScanDaily => 'Daily';
@override
String get libraryAutoScanWeekly => 'Weekly';
@override
String get libraryActions => 'Actions';
@@ -2101,6 +2164,28 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String get queueFlacAction => 'Queue FLAC';
@override
String queueFlacConfirmMessage(int count) {
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
}
@override
String queueFlacFindingProgress(int current, int total) {
return 'Finding FLAC matches... ($current/$total)';
}
@override
String get queueFlacNoReliableMatches =>
'No reliable online matches found for the selection';
@override
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
return 'Added $addedCount tracks to queue, skipped $skippedCount';
}
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
@@ -2110,7 +2195,8 @@ class AppLocalizationsEn extends AppLocalizations {
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
String get trackConvertFormatSubtitle =>
'Convert to MP3, Opus, ALAC, or FLAC';
@override
String get trackConvertTitle => 'Convert Audio';
@@ -2133,6 +2219,18 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertLosslessHint =>
'Lossless conversion — no quality loss';
@override
String get trackConvertConverting => 'Converting audio...';
@@ -2386,6 +2484,17 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
@@ -2408,4 +2517,402 @@ 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 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';
}
+508 -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';
@@ -1195,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)';
@@ -1651,6 +1695,25 @@ class AppLocalizationsEs extends AppLocalizations {
String get libraryShowDuplicateIndicatorSubtitle =>
'Show when searching for existing tracks';
@override
String get libraryAutoScan => 'Auto Scan';
@override
String get libraryAutoScanSubtitle =>
'Automatically scan your library for new files';
@override
String get libraryAutoScanOff => 'Off';
@override
String get libraryAutoScanOnOpen => 'Every app open';
@override
String get libraryAutoScanDaily => 'Daily';
@override
String get libraryAutoScanWeekly => 'Weekly';
@override
String get libraryActions => 'Actions';
@@ -2101,6 +2164,28 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String get queueFlacAction => 'Queue FLAC';
@override
String queueFlacConfirmMessage(int count) {
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
}
@override
String queueFlacFindingProgress(int current, int total) {
return 'Finding FLAC matches... ($current/$total)';
}
@override
String get queueFlacNoReliableMatches =>
'No reliable online matches found for the selection';
@override
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
return 'Added $addedCount tracks to queue, skipped $skippedCount';
}
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
@@ -2110,7 +2195,8 @@ class AppLocalizationsEs extends AppLocalizations {
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
String get trackConvertFormatSubtitle =>
'Convert to MP3, Opus, ALAC, or FLAC';
@override
String get trackConvertTitle => 'Convert Audio';
@@ -2133,6 +2219,18 @@ class AppLocalizationsEs extends AppLocalizations {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertLosslessHint =>
'Lossless conversion — no quality loss';
@override
String get trackConvertConverting => 'Converting audio...';
@@ -2386,6 +2484,17 @@ class AppLocalizationsEs extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
@@ -2408,6 +2517,404 @@ 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 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`).
+506
View File
@@ -527,6 +527,9 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get dialogImport => 'Import';
@override
String get dialogDownload => 'Download';
@override
String get dialogDiscard => 'Discard';
@@ -1197,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)';
@@ -1653,6 +1697,25 @@ class AppLocalizationsFr extends AppLocalizations {
String get libraryShowDuplicateIndicatorSubtitle =>
'Show when searching for existing tracks';
@override
String get libraryAutoScan => 'Auto Scan';
@override
String get libraryAutoScanSubtitle =>
'Automatically scan your library for new files';
@override
String get libraryAutoScanOff => 'Off';
@override
String get libraryAutoScanOnOpen => 'Every app open';
@override
String get libraryAutoScanDaily => 'Daily';
@override
String get libraryAutoScanWeekly => 'Weekly';
@override
String get libraryActions => 'Actions';
@@ -2103,6 +2166,28 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String get queueFlacAction => 'Queue FLAC';
@override
String queueFlacConfirmMessage(int count) {
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
}
@override
String queueFlacFindingProgress(int current, int total) {
return 'Finding FLAC matches... ($current/$total)';
}
@override
String get queueFlacNoReliableMatches =>
'No reliable online matches found for the selection';
@override
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
return 'Added $addedCount tracks to queue, skipped $skippedCount';
}
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
@@ -2135,6 +2220,18 @@ class AppLocalizationsFr extends AppLocalizations {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertLosslessHint =>
'Lossless conversion — no quality loss';
@override
String get trackConvertConverting => 'Converting audio...';
@@ -2388,6 +2485,17 @@ class AppLocalizationsFr extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
@@ -2410,4 +2518,402 @@ 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 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';
}
+506
View File
@@ -525,6 +525,9 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get dialogImport => 'Import';
@override
String get dialogDownload => 'Download';
@override
String get dialogDiscard => 'Discard';
@@ -1195,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)';
@@ -1651,6 +1695,25 @@ class AppLocalizationsHi extends AppLocalizations {
String get libraryShowDuplicateIndicatorSubtitle =>
'Show when searching for existing tracks';
@override
String get libraryAutoScan => 'Auto Scan';
@override
String get libraryAutoScanSubtitle =>
'Automatically scan your library for new files';
@override
String get libraryAutoScanOff => 'Off';
@override
String get libraryAutoScanOnOpen => 'Every app open';
@override
String get libraryAutoScanDaily => 'Daily';
@override
String get libraryAutoScanWeekly => 'Weekly';
@override
String get libraryActions => 'Actions';
@@ -2101,6 +2164,28 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String get queueFlacAction => 'Queue FLAC';
@override
String queueFlacConfirmMessage(int count) {
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
}
@override
String queueFlacFindingProgress(int current, int total) {
return 'Finding FLAC matches... ($current/$total)';
}
@override
String get queueFlacNoReliableMatches =>
'No reliable online matches found for the selection';
@override
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
return 'Added $addedCount tracks to queue, skipped $skippedCount';
}
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
@@ -2133,6 +2218,18 @@ class AppLocalizationsHi extends AppLocalizations {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertLosslessHint =>
'Lossless conversion — no quality loss';
@override
String get trackConvertConverting => 'Converting audio...';
@@ -2386,6 +2483,17 @@ class AppLocalizationsHi extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
@@ -2408,4 +2516,402 @@ 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 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';
}
+508 -1
View File
@@ -528,6 +528,9 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get dialogImport => 'Impor';
@override
String get dialogDownload => 'Download';
@override
String get dialogDiscard => 'Buang';
@@ -1200,6 +1203,47 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get storeClearFilters => 'Hapus filter';
@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)';
@@ -1658,6 +1702,25 @@ class AppLocalizationsId extends AppLocalizations {
String get libraryShowDuplicateIndicatorSubtitle =>
'Show when searching for existing tracks';
@override
String get libraryAutoScan => 'Auto Scan';
@override
String get libraryAutoScanSubtitle =>
'Automatically scan your library for new files';
@override
String get libraryAutoScanOff => 'Off';
@override
String get libraryAutoScanOnOpen => 'Every app open';
@override
String get libraryAutoScanDaily => 'Daily';
@override
String get libraryAutoScanWeekly => 'Weekly';
@override
String get libraryActions => 'Actions';
@@ -2108,6 +2171,28 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String get queueFlacAction => 'Antrekan FLAC';
@override
String queueFlacConfirmMessage(int count) {
return 'Cari kecocokan online untuk track yang dipilih lalu antrekan download FLAC.\n\nFile yang sudah ada tidak akan diubah atau dihapus.\n\nHanya kecocokan dengan keyakinan tinggi yang akan diantrikan otomatis.\n\n$count dipilih';
}
@override
String queueFlacFindingProgress(int current, int total) {
return 'Mencari kecocokan FLAC... ($current/$total)';
}
@override
String get queueFlacNoReliableMatches =>
'Tidak ada kecocokan online yang cukup meyakinkan untuk pilihan ini';
@override
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
return 'Menambahkan $addedCount track ke antrean, melewati $skippedCount';
}
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
@@ -2117,7 +2202,8 @@ class AppLocalizationsId extends AppLocalizations {
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
String get trackConvertFormatSubtitle =>
'Konversi ke MP3, Opus, ALAC, atau FLAC';
@override
String get trackConvertTitle => 'Convert Audio';
@@ -2140,6 +2226,18 @@ class AppLocalizationsId extends AppLocalizations {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Konversi dari $sourceFormat ke $targetFormat? (Lossless — tanpa kehilangan kualitas)\n\nFile asli akan dihapus setelah konversi.';
}
@override
String get trackConvertLosslessHint =>
'Konversi lossless — tanpa kehilangan kualitas';
@override
String get trackConvertConverting => 'Converting audio...';
@@ -2393,6 +2491,17 @@ class AppLocalizationsId extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
@@ -2415,4 +2524,402 @@ class AppLocalizationsId 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 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';
}
+506
View File
@@ -521,6 +521,9 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get dialogImport => 'インポート';
@override
String get dialogDownload => 'Download';
@override
String get dialogDiscard => '破棄';
@@ -1189,6 +1192,47 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get storeClearFilters => 'フィルターを消去';
@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 => 'デフォルト (Deezer/Spotify)';
@@ -1638,6 +1682,25 @@ class AppLocalizationsJa extends AppLocalizations {
String get libraryShowDuplicateIndicatorSubtitle =>
'Show when searching for existing tracks';
@override
String get libraryAutoScan => 'Auto Scan';
@override
String get libraryAutoScanSubtitle =>
'Automatically scan your library for new files';
@override
String get libraryAutoScanOff => 'Off';
@override
String get libraryAutoScanOnOpen => 'Every app open';
@override
String get libraryAutoScanDaily => 'Daily';
@override
String get libraryAutoScanWeekly => 'Weekly';
@override
String get libraryActions => 'アクション';
@@ -2088,6 +2151,28 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String get queueFlacAction => 'Queue FLAC';
@override
String queueFlacConfirmMessage(int count) {
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
}
@override
String queueFlacFindingProgress(int current, int total) {
return 'Finding FLAC matches... ($current/$total)';
}
@override
String get queueFlacNoReliableMatches =>
'No reliable online matches found for the selection';
@override
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
return 'Added $addedCount tracks to queue, skipped $skippedCount';
}
@override
String trackSaveFailed(String error) {
return '失敗: $error';
@@ -2120,6 +2205,18 @@ class AppLocalizationsJa extends AppLocalizations {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertLosslessHint =>
'Lossless conversion — no quality loss';
@override
String get trackConvertConverting => 'オーディオを変換中...';
@@ -2373,6 +2470,17 @@ class AppLocalizationsJa extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
@@ -2395,4 +2503,402 @@ class AppLocalizationsJa 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 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';
}
+506
View File
@@ -510,6 +510,9 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get dialogImport => '불러오기';
@override
String get dialogDownload => 'Download';
@override
String get dialogDiscard => '취소';
@@ -1175,6 +1178,47 @@ class AppLocalizationsKo 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)';
@@ -1631,6 +1675,25 @@ class AppLocalizationsKo extends AppLocalizations {
String get libraryShowDuplicateIndicatorSubtitle =>
'Show when searching for existing tracks';
@override
String get libraryAutoScan => 'Auto Scan';
@override
String get libraryAutoScanSubtitle =>
'Automatically scan your library for new files';
@override
String get libraryAutoScanOff => 'Off';
@override
String get libraryAutoScanOnOpen => 'Every app open';
@override
String get libraryAutoScanDaily => 'Daily';
@override
String get libraryAutoScanWeekly => 'Weekly';
@override
String get libraryActions => 'Actions';
@@ -2081,6 +2144,28 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String get queueFlacAction => 'Queue FLAC';
@override
String queueFlacConfirmMessage(int count) {
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
}
@override
String queueFlacFindingProgress(int current, int total) {
return 'Finding FLAC matches... ($current/$total)';
}
@override
String get queueFlacNoReliableMatches =>
'No reliable online matches found for the selection';
@override
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
return 'Added $addedCount tracks to queue, skipped $skippedCount';
}
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
@@ -2113,6 +2198,18 @@ class AppLocalizationsKo extends AppLocalizations {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertLosslessHint =>
'Lossless conversion — no quality loss';
@override
String get trackConvertConverting => 'Converting audio...';
@@ -2366,6 +2463,17 @@ class AppLocalizationsKo extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
@@ -2388,4 +2496,402 @@ class AppLocalizationsKo 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 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';
}
+506
View File
@@ -525,6 +525,9 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get dialogImport => 'Import';
@override
String get dialogDownload => 'Download';
@override
String get dialogDiscard => 'Discard';
@@ -1195,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)';
@@ -1651,6 +1695,25 @@ class AppLocalizationsNl extends AppLocalizations {
String get libraryShowDuplicateIndicatorSubtitle =>
'Show when searching for existing tracks';
@override
String get libraryAutoScan => 'Auto Scan';
@override
String get libraryAutoScanSubtitle =>
'Automatically scan your library for new files';
@override
String get libraryAutoScanOff => 'Off';
@override
String get libraryAutoScanOnOpen => 'Every app open';
@override
String get libraryAutoScanDaily => 'Daily';
@override
String get libraryAutoScanWeekly => 'Weekly';
@override
String get libraryActions => 'Actions';
@@ -2101,6 +2164,28 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String get queueFlacAction => 'Queue FLAC';
@override
String queueFlacConfirmMessage(int count) {
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
}
@override
String queueFlacFindingProgress(int current, int total) {
return 'Finding FLAC matches... ($current/$total)';
}
@override
String get queueFlacNoReliableMatches =>
'No reliable online matches found for the selection';
@override
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
return 'Added $addedCount tracks to queue, skipped $skippedCount';
}
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
@@ -2133,6 +2218,18 @@ class AppLocalizationsNl extends AppLocalizations {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertLosslessHint =>
'Lossless conversion — no quality loss';
@override
String get trackConvertConverting => 'Converting audio...';
@@ -2386,6 +2483,17 @@ class AppLocalizationsNl extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
@@ -2408,4 +2516,402 @@ 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 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';
}
+508 -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';
@@ -1195,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)';
@@ -1651,6 +1695,25 @@ class AppLocalizationsPt extends AppLocalizations {
String get libraryShowDuplicateIndicatorSubtitle =>
'Show when searching for existing tracks';
@override
String get libraryAutoScan => 'Auto Scan';
@override
String get libraryAutoScanSubtitle =>
'Automatically scan your library for new files';
@override
String get libraryAutoScanOff => 'Off';
@override
String get libraryAutoScanOnOpen => 'Every app open';
@override
String get libraryAutoScanDaily => 'Daily';
@override
String get libraryAutoScanWeekly => 'Weekly';
@override
String get libraryActions => 'Actions';
@@ -2101,6 +2164,28 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String get queueFlacAction => 'Queue FLAC';
@override
String queueFlacConfirmMessage(int count) {
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
}
@override
String queueFlacFindingProgress(int current, int total) {
return 'Finding FLAC matches... ($current/$total)';
}
@override
String get queueFlacNoReliableMatches =>
'No reliable online matches found for the selection';
@override
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
return 'Added $addedCount tracks to queue, skipped $skippedCount';
}
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
@@ -2110,7 +2195,8 @@ class AppLocalizationsPt extends AppLocalizations {
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
String get trackConvertFormatSubtitle =>
'Convert to MP3, Opus, ALAC, or FLAC';
@override
String get trackConvertTitle => 'Convert Audio';
@@ -2133,6 +2219,18 @@ class AppLocalizationsPt extends AppLocalizations {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertLosslessHint =>
'Lossless conversion — no quality loss';
@override
String get trackConvertConverting => 'Converting audio...';
@@ -2386,6 +2484,17 @@ class AppLocalizationsPt extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
@@ -2408,6 +2517,404 @@ 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 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`).
+506
View File
@@ -534,6 +534,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get dialogImport => 'Импорт';
@override
String get dialogDownload => 'Download';
@override
String get dialogDiscard => 'Отменить';
@@ -1216,6 +1219,47 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get storeClearFilters => 'Очистить фильтры';
@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 => 'По умолчанию (Deezer/Spotify)';
@@ -1687,6 +1731,25 @@ class AppLocalizationsRu extends AppLocalizations {
String get libraryShowDuplicateIndicatorSubtitle =>
'Показать при поиске существующих треков';
@override
String get libraryAutoScan => 'Auto Scan';
@override
String get libraryAutoScanSubtitle =>
'Automatically scan your library for new files';
@override
String get libraryAutoScanOff => 'Off';
@override
String get libraryAutoScanOnOpen => 'Every app open';
@override
String get libraryAutoScanDaily => 'Daily';
@override
String get libraryAutoScanWeekly => 'Weekly';
@override
String get libraryActions => 'Действия';
@@ -2154,6 +2217,28 @@ class AppLocalizationsRu extends AppLocalizations {
String get trackReEnrichFfmpegFailed =>
'Ошибка встраивания метаданных FFmpeg';
@override
String get queueFlacAction => 'Queue FLAC';
@override
String queueFlacConfirmMessage(int count) {
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
}
@override
String queueFlacFindingProgress(int current, int total) {
return 'Finding FLAC matches... ($current/$total)';
}
@override
String get queueFlacNoReliableMatches =>
'No reliable online matches found for the selection';
@override
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
return 'Added $addedCount tracks to queue, skipped $skippedCount';
}
@override
String trackSaveFailed(String error) {
return 'Ошибка: $error';
@@ -2186,6 +2271,18 @@ class AppLocalizationsRu extends AppLocalizations {
return 'Конвертировать из $sourceFormat в $targetFormat $bitrate?\n\nОригинальный файл будет удален после конвертации.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertLosslessHint =>
'Lossless conversion — no quality loss';
@override
String get trackConvertConverting => 'Конвертация аудио...';
@@ -2445,6 +2542,17 @@ class AppLocalizationsRu extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Конвертация $current из $total...';
@@ -2467,4 +2575,402 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Папки исполнителя используют только трек исполнителя';
@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 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';
}
+506
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ç';
@@ -1206,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)';
@@ -1663,6 +1707,25 @@ class AppLocalizationsTr extends AppLocalizations {
String get libraryShowDuplicateIndicatorSubtitle =>
'Show when searching for existing tracks';
@override
String get libraryAutoScan => 'Auto Scan';
@override
String get libraryAutoScanSubtitle =>
'Automatically scan your library for new files';
@override
String get libraryAutoScanOff => 'Off';
@override
String get libraryAutoScanOnOpen => 'Every app open';
@override
String get libraryAutoScanDaily => 'Daily';
@override
String get libraryAutoScanWeekly => 'Weekly';
@override
String get libraryActions => 'Actions';
@@ -2113,6 +2176,28 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String get queueFlacAction => 'Queue FLAC';
@override
String queueFlacConfirmMessage(int count) {
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
}
@override
String queueFlacFindingProgress(int current, int total) {
return 'Finding FLAC matches... ($current/$total)';
}
@override
String get queueFlacNoReliableMatches =>
'No reliable online matches found for the selection';
@override
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
return 'Added $addedCount tracks to queue, skipped $skippedCount';
}
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
@@ -2145,6 +2230,18 @@ class AppLocalizationsTr extends AppLocalizations {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertLosslessHint =>
'Lossless conversion — no quality loss';
@override
String get trackConvertConverting => 'Converting audio...';
@@ -2398,6 +2495,17 @@ class AppLocalizationsTr extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
@@ -2420,4 +2528,402 @@ 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 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';
}
+508 -1
View File
@@ -525,6 +525,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get dialogImport => 'Import';
@override
String get dialogDownload => 'Download';
@override
String get dialogDiscard => 'Discard';
@@ -1195,6 +1198,47 @@ class AppLocalizationsZh 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)';
@@ -1651,6 +1695,25 @@ class AppLocalizationsZh extends AppLocalizations {
String get libraryShowDuplicateIndicatorSubtitle =>
'Show when searching for existing tracks';
@override
String get libraryAutoScan => 'Auto Scan';
@override
String get libraryAutoScanSubtitle =>
'Automatically scan your library for new files';
@override
String get libraryAutoScanOff => 'Off';
@override
String get libraryAutoScanOnOpen => 'Every app open';
@override
String get libraryAutoScanDaily => 'Daily';
@override
String get libraryAutoScanWeekly => 'Weekly';
@override
String get libraryActions => 'Actions';
@@ -2101,6 +2164,28 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String get queueFlacAction => 'Queue FLAC';
@override
String queueFlacConfirmMessage(int count) {
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
}
@override
String queueFlacFindingProgress(int current, int total) {
return 'Finding FLAC matches... ($current/$total)';
}
@override
String get queueFlacNoReliableMatches =>
'No reliable online matches found for the selection';
@override
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
return 'Added $addedCount tracks to queue, skipped $skippedCount';
}
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
@@ -2110,7 +2195,8 @@ class AppLocalizationsZh extends AppLocalizations {
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
String get trackConvertFormatSubtitle =>
'Convert to MP3, Opus, ALAC, or FLAC';
@override
String get trackConvertTitle => 'Convert Audio';
@@ -2133,6 +2219,18 @@ class AppLocalizationsZh extends AppLocalizations {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertLosslessHint =>
'Lossless conversion — no quality loss';
@override
String get trackConvertConverting => 'Converting audio...';
@@ -2386,6 +2484,17 @@ class AppLocalizationsZh extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
@@ -2408,6 +2517,404 @@ class AppLocalizationsZh 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 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 Chinese, as used in China (`zh_CN`).
+643 -1
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,6 +1574,58 @@
"@storeClearFilters": {
"description": "Button to clear all filters"
},
"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/Spotify)",
"@extensionDefaultProvider": {
"description": "Default search provider option"
@@ -2186,6 +2242,30 @@
"@libraryShowDuplicateIndicatorSubtitle": {
"description": "Subtitle for duplicate indicator toggle"
},
"libraryAutoScan": "Auto Scan",
"@libraryAutoScan": {
"description": "Setting for automatic library scanning"
},
"libraryAutoScanSubtitle": "Automatically scan your library for new files",
"@libraryAutoScanSubtitle": {
"description": "Subtitle for auto scan setting"
},
"libraryAutoScanOff": "Off",
"@libraryAutoScanOff": {
"description": "Auto scan disabled"
},
"libraryAutoScanOnOpen": "Every app open",
"@libraryAutoScanOnOpen": {
"description": "Auto scan when app opens"
},
"libraryAutoScanDaily": "Daily",
"@libraryAutoScanDaily": {
"description": "Auto scan once per day"
},
"libraryAutoScanWeekly": "Weekly",
"@libraryAutoScanWeekly": {
"description": "Auto scan once per week"
},
"libraryActions": "Actions",
"@libraryActions": {
"description": "Section header for library actions"
@@ -2763,6 +2843,47 @@
"@trackReEnrichFfmpegFailed": {
"description": "Snackbar when FFmpeg embed fails for MP3/Opus"
},
"queueFlacAction": "Queue FLAC",
"@queueFlacAction": {
"description": "Action/button label for queueing FLAC redownloads for local tracks"
},
"queueFlacConfirmMessage": "Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n{count} selected",
"@queueFlacConfirmMessage": {
"description": "Confirmation dialog body before queueing FLAC redownloads for local tracks",
"placeholders": {
"count": {
"type": "int"
}
}
},
"queueFlacFindingProgress": "Finding FLAC matches... ({current}/{total})",
"@queueFlacFindingProgress": {
"description": "Snackbar while resolving remote matches for local FLAC redownloads",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"queueFlacNoReliableMatches": "No reliable online matches found for the selection",
"@queueFlacNoReliableMatches": {
"description": "Snackbar when no safe FLAC redownload matches were found"
},
"queueFlacQueuedWithSkipped": "Added {addedCount} tracks to queue, skipped {skippedCount}",
"@queueFlacQueuedWithSkipped": {
"description": "Snackbar when some selected local tracks were queued for FLAC redownload and some were skipped",
"placeholders": {
"addedCount": {
"type": "int"
},
"skippedCount": {
"type": "int"
}
}
},
"trackSaveFailed": "Failed: {error}",
"@trackSaveFailed": {
"description": "Snackbar when save operation fails",
@@ -2776,7 +2897,7 @@
"@trackConvertFormat": {
"description": "Menu item - convert audio format"
},
"trackConvertFormatSubtitle": "Convert to MP3 or Opus",
"trackConvertFormatSubtitle": "Convert to MP3, Opus, ALAC, or FLAC",
"@trackConvertFormatSubtitle": {
"description": "Subtitle for convert format menu item"
},
@@ -2811,6 +2932,22 @@
}
}
},
"trackConvertConfirmMessageLossless": "Convert from {sourceFormat} to {targetFormat}? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.",
"@trackConvertConfirmMessageLossless": {
"description": "Confirmation dialog message for lossless-to-lossless conversion",
"placeholders": {
"sourceFormat": {
"type": "String"
},
"targetFormat": {
"type": "String"
}
}
},
"trackConvertLosslessHint": "Lossless conversion — no quality loss",
"@trackConvertLosslessHint": {
"description": "Hint shown when converting between lossless formats"
},
"trackConvertConverting": "Converting audio...",
"@trackConvertConverting": {
"description": "Snackbar while converting"
@@ -3162,6 +3299,18 @@
}
}
},
"selectionBatchConvertConfirmMessageLossless": "Convert {count} {count, plural, =1{track} other{tracks}} to {format}? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.",
"@selectionBatchConvertConfirmMessageLossless": {
"description": "Confirmation dialog message for lossless batch conversion",
"placeholders": {
"count": {
"type": "int"
},
"format": {
"type": "String"
}
}
},
"selectionBatchConvertProgress": "Converting {current} of {total}...",
"@selectionBatchConvertProgress": {
"description": "Snackbar during batch conversion progress",
@@ -3205,5 +3354,498 @@
"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"
},
"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"
}
}
+59 -2
View File
@@ -2755,6 +2755,47 @@
"@trackReEnrichFfmpegFailed": {
"description": "Snackbar when FFmpeg embed fails for MP3/Opus"
},
"queueFlacAction": "Antrekan FLAC",
"@queueFlacAction": {
"description": "Action/button label for queueing FLAC redownloads for local tracks"
},
"queueFlacConfirmMessage": "Cari kecocokan online untuk track yang dipilih lalu antrekan download FLAC.\n\nFile yang sudah ada tidak akan diubah atau dihapus.\n\nHanya kecocokan dengan keyakinan tinggi yang akan diantrikan otomatis.\n\n{count} dipilih",
"@queueFlacConfirmMessage": {
"description": "Confirmation dialog body before queueing FLAC redownloads for local tracks",
"placeholders": {
"count": {
"type": "int"
}
}
},
"queueFlacFindingProgress": "Mencari kecocokan FLAC... ({current}/{total})",
"@queueFlacFindingProgress": {
"description": "Snackbar while resolving remote matches for local FLAC redownloads",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"queueFlacNoReliableMatches": "Tidak ada kecocokan online yang cukup meyakinkan untuk pilihan ini",
"@queueFlacNoReliableMatches": {
"description": "Snackbar when no safe FLAC redownload matches were found"
},
"queueFlacQueuedWithSkipped": "Menambahkan {addedCount} track ke antrean, melewati {skippedCount}",
"@queueFlacQueuedWithSkipped": {
"description": "Snackbar when some selected local tracks were queued for FLAC redownload and some were skipped",
"placeholders": {
"addedCount": {
"type": "int"
},
"skippedCount": {
"type": "int"
}
}
},
"trackSaveFailed": "Failed: {error}",
"@trackSaveFailed": {
"description": "Snackbar when save operation fails",
@@ -2768,7 +2809,7 @@
"@trackConvertFormat": {
"description": "Menu item - convert audio format"
},
"trackConvertFormatSubtitle": "Convert to MP3 or Opus",
"trackConvertFormatSubtitle": "Konversi ke MP3, Opus, ALAC, atau FLAC",
"@trackConvertFormatSubtitle": {
"description": "Subtitle for convert format menu item"
},
@@ -2803,6 +2844,22 @@
}
}
},
"trackConvertConfirmMessageLossless": "Konversi dari {sourceFormat} ke {targetFormat}? (Lossless — tanpa kehilangan kualitas)\n\nFile asli akan dihapus setelah konversi.",
"@trackConvertConfirmMessageLossless": {
"description": "Confirmation dialog message for lossless-to-lossless conversion",
"placeholders": {
"sourceFormat": {
"type": "String"
},
"targetFormat": {
"type": "String"
}
}
},
"trackConvertLosslessHint": "Konversi lossless — tanpa kehilangan kualitas",
"@trackConvertLosslessHint": {
"description": "Hint shown when converting between lossless formats"
},
"trackConvertConverting": "Converting audio...",
"@trackConvertConverting": {
"description": "Snackbar while converting"
@@ -3114,4 +3171,4 @@
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
"description": "Subtitle when Track Artist is used for folder naming"
}
}
}
+137 -6
View File
@@ -1,13 +1,16 @@
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';
@@ -88,15 +91,143 @@ 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;
static const _lastScannedAtKey = 'local_library_last_scanned_at';
@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 lastScannedMs = prefs.getInt(_lastScannedAtKey);
if (lastScannedMs != null) {
final lastScanned = DateTime.fromMillisecondsSinceEpoch(lastScannedMs);
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 {
+7 -6
View File
@@ -38,10 +38,8 @@ class AppSettings {
final bool showExtensionStore;
final String locale;
final String lyricsMode;
final String
tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'opus_256', or 'opus_128'
final int
youtubeOpusBitrate; // YouTube Opus bitrate (supported: 128/256 kbps)
youtubeOpusBitrate; // YouTube Opus bitrate (supported: 128/256/320 kbps)
final int
youtubeMp3Bitrate; // YouTube MP3 bitrate (supported: 128/256/320 kbps)
final bool
@@ -61,6 +59,8 @@ class AppSettings {
localLibraryBookmark; // Base64-encoded iOS security-scoped bookmark
final bool
localLibraryShowDuplicates; // Show indicator when searching for existing tracks
final String
localLibraryAutoScan; // Auto-scan mode: 'off', 'on_open', 'daily', 'weekly'
final bool
hasCompletedTutorial; // Track if user has completed the app tutorial
@@ -114,7 +114,6 @@ class AppSettings {
this.showExtensionStore = true,
this.locale = 'system',
this.lyricsMode = 'embed',
this.tidalHighFormat = 'mp3_320',
this.youtubeOpusBitrate = 256,
this.youtubeMp3Bitrate = 320,
this.useAllFilesAccess = false,
@@ -126,6 +125,7 @@ class AppSettings {
this.localLibraryPath = '',
this.localLibraryBookmark = '',
this.localLibraryShowDuplicates = true,
this.localLibraryAutoScan = 'off',
this.hasCompletedTutorial = false,
this.lyricsProviders = const [
'lrclib',
@@ -178,7 +178,6 @@ class AppSettings {
bool? showExtensionStore,
String? locale,
String? lyricsMode,
String? tidalHighFormat,
int? youtubeOpusBitrate,
int? youtubeMp3Bitrate,
bool? useAllFilesAccess,
@@ -190,6 +189,7 @@ class AppSettings {
String? localLibraryPath,
String? localLibraryBookmark,
bool? localLibraryShowDuplicates,
String? localLibraryAutoScan,
bool? hasCompletedTutorial,
List<String>? lyricsProviders,
bool? lyricsIncludeTranslationNetease,
@@ -241,7 +241,6 @@ class AppSettings {
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
locale: locale ?? this.locale,
lyricsMode: lyricsMode ?? this.lyricsMode,
tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat,
youtubeOpusBitrate: youtubeOpusBitrate ?? this.youtubeOpusBitrate,
youtubeMp3Bitrate: youtubeMp3Bitrate ?? this.youtubeMp3Bitrate,
useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess,
@@ -256,6 +255,8 @@ class AppSettings {
localLibraryBookmark: localLibraryBookmark ?? this.localLibraryBookmark,
localLibraryShowDuplicates:
localLibraryShowDuplicates ?? this.localLibraryShowDuplicates,
localLibraryAutoScan:
localLibraryAutoScan ?? this.localLibraryAutoScan,
hasCompletedTutorial: hasCompletedTutorial ?? this.hasCompletedTutorial,
lyricsProviders: lyricsProviders ?? this.lyricsProviders,
lyricsIncludeTranslationNetease:
+2 -2
View File
@@ -44,7 +44,6 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
showExtensionStore: json['showExtensionStore'] as bool? ?? true,
locale: json['locale'] as String? ?? 'system',
lyricsMode: json['lyricsMode'] as String? ?? 'embed',
tidalHighFormat: json['tidalHighFormat'] as String? ?? 'mp3_320',
youtubeOpusBitrate: (json['youtubeOpusBitrate'] as num?)?.toInt() ?? 256,
youtubeMp3Bitrate: (json['youtubeMp3Bitrate'] as num?)?.toInt() ?? 320,
useAllFilesAccess: json['useAllFilesAccess'] as bool? ?? false,
@@ -58,6 +57,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
localLibraryBookmark: json['localLibraryBookmark'] as String? ?? '',
localLibraryShowDuplicates:
json['localLibraryShowDuplicates'] as bool? ?? true,
localLibraryAutoScan: json['localLibraryAutoScan'] as String? ?? 'off',
hasCompletedTutorial: json['hasCompletedTutorial'] as bool? ?? false,
lyricsProviders:
(json['lyricsProviders'] as List<dynamic>?)
@@ -119,7 +119,6 @@ Map<String, dynamic> _$AppSettingsToJson(
'showExtensionStore': instance.showExtensionStore,
'locale': instance.locale,
'lyricsMode': instance.lyricsMode,
'tidalHighFormat': instance.tidalHighFormat,
'youtubeOpusBitrate': instance.youtubeOpusBitrate,
'youtubeMp3Bitrate': instance.youtubeMp3Bitrate,
'useAllFilesAccess': instance.useAllFilesAccess,
@@ -131,6 +130,7 @@ Map<String, dynamic> _$AppSettingsToJson(
'localLibraryPath': instance.localLibraryPath,
'localLibraryBookmark': instance.localLibraryBookmark,
'localLibraryShowDuplicates': instance.localLibraryShowDuplicates,
'localLibraryAutoScan': instance.localLibraryAutoScan,
'hasCompletedTutorial': instance.hasCompletedTutorial,
'lyricsProviders': instance.lyricsProviders,
'lyricsIncludeTranslationNetease': instance.lyricsIncludeTranslationNetease,
+255 -327
View File
@@ -205,6 +205,7 @@ class DownloadHistoryState {
final List<DownloadHistoryItem> items;
final Map<String, DownloadHistoryItem> _bySpotifyId;
final Map<String, DownloadHistoryItem> _byIsrc;
final Map<String, DownloadHistoryItem> _byTrackArtistKey;
DownloadHistoryState({this.items = const []})
: _bySpotifyId = Map.fromEntries(
@@ -218,8 +219,25 @@ class DownloadHistoryState {
items
.where((item) => item.isrc != null && item.isrc!.isNotEmpty)
.map((item) => MapEntry(item.isrc!, item)),
),
_byTrackArtistKey = Map.fromEntries(
items
.map(
(item) => MapEntry(
_trackArtistKey(item.trackName, item.artistName),
item,
),
)
.where((entry) => entry.key.isNotEmpty),
);
static String _trackArtistKey(String trackName, String artistName) {
final normalizedTrack = trackName.trim().toLowerCase();
if (normalizedTrack.isEmpty) return '';
final normalizedArtist = artistName.trim().toLowerCase();
return '$normalizedTrack|$normalizedArtist';
}
bool isDownloaded(String spotifyId) => _bySpotifyId.containsKey(spotifyId);
DownloadHistoryItem? getBySpotifyId(String spotifyId) =>
@@ -231,16 +249,9 @@ class DownloadHistoryState {
String trackName,
String artistName,
) {
final normalizedTrack = trackName.trim().toLowerCase();
final normalizedArtist = artistName.trim().toLowerCase();
if (normalizedTrack.isEmpty) return null;
for (final item in items) {
if (item.trackName.trim().toLowerCase() == normalizedTrack &&
item.artistName.trim().toLowerCase() == normalizedArtist) {
return item;
}
}
return null;
final key = _trackArtistKey(trackName, artistName);
if (key.isEmpty) return null;
return _byTrackArtistKey[key];
}
DownloadHistoryState copyWith({List<DownloadHistoryItem>? items}) {
@@ -252,10 +263,12 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
static const int _safRepairBatchSize = 20;
static const int _safRepairMaxPerLaunch = 60;
static const int _audioMetadataBackfillMaxPerLaunch = 24;
static const _startupMaintenanceDelay = Duration(seconds: 2);
final HistoryDatabase _db = HistoryDatabase.instance;
bool _isLoaded = false;
bool _isSafRepairInProgress = false;
bool _isAudioMetadataBackfillInProgress = false;
bool _startupMaintenanceScheduled = false;
@override
DownloadHistoryState build() {
@@ -292,33 +305,45 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
state = state.copyWith(items: items);
_historyLog.i('Loaded ${items.length} items from SQLite database');
if (Platform.isAndroid) {
Future.microtask(() async {
await _repairMissingSafEntries(
items,
maxItems: _safRepairMaxPerLaunch,
);
await cleanupOrphanedDownloads();
await _backfillAudioMetadata(
state.items,
maxItems: _audioMetadataBackfillMaxPerLaunch,
);
});
} else {
Future.microtask(() async {
await cleanupOrphanedDownloads();
await _backfillAudioMetadata(
state.items,
maxItems: _audioMetadataBackfillMaxPerLaunch,
);
});
}
_scheduleStartupMaintenance(items);
} catch (e, stack) {
_historyLog.e('Failed to load history from database: $e', e, stack);
}
}
void _scheduleStartupMaintenance(List<DownloadHistoryItem> initialItems) {
if (_startupMaintenanceScheduled) {
return;
}
_startupMaintenanceScheduled = true;
unawaited(
Future<void>.delayed(_startupMaintenanceDelay, () async {
try {
if (Platform.isAndroid) {
await _repairMissingSafEntries(
initialItems,
maxItems: _safRepairMaxPerLaunch,
);
}
await cleanupOrphanedDownloads();
final currentItems = state.items;
if (currentItems.isNotEmpty) {
await _backfillAudioMetadata(
currentItems,
maxItems: _audioMetadataBackfillMaxPerLaunch,
);
}
} catch (e, stack) {
_historyLog.w('Startup history maintenance failed: $e');
_historyLog.d('$stack');
}
}),
);
}
String _fileNameFromUri(String uri) {
try {
final parsed = Uri.parse(uri);
@@ -745,6 +770,37 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
/// Remove history entries where the file no longer exists on disk
/// Returns the number of orphaned entries removed
/// Audio file extensions that the app commonly produces or converts between.
static const _audioExtensions = [
'.flac',
'.m4a',
'.mp3',
'.opus',
'.ogg',
'.wav',
'.aac',
];
/// When the original file is missing, check whether a sibling with a
/// different audio extension exists (e.g. the user converted .flac .opus).
/// Returns the path of the first match found, or `null` if none exist.
Future<String?> _findConvertedSibling(String originalPath) async {
// Strip the current extension to get the base path.
final dotIndex = originalPath.lastIndexOf('.');
if (dotIndex < 0) return null;
final basePath = originalPath.substring(0, dotIndex);
final originalExt = originalPath.substring(dotIndex).toLowerCase();
for (final ext in _audioExtensions) {
if (ext == originalExt) continue;
final candidatePath = '$basePath$ext';
try {
if (await fileExists(candidatePath)) return candidatePath;
} catch (_) {}
}
return null;
}
Future<int> cleanupOrphanedDownloads() async {
_historyLog.i('Starting orphaned downloads cleanup...');
@@ -766,7 +822,21 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
if (filePath == null || filePath.isEmpty) return null;
pathById[id] = filePath;
try {
return MapEntry(id, await fileExists(filePath));
if (await fileExists(filePath)) return MapEntry(id, true);
// Original file missing -- check for a converted sibling.
final sibling = await _findConvertedSibling(filePath);
if (sibling != null) {
_historyLog.i(
'Found converted sibling for $id: $filePath$sibling',
);
// Update the stored path so future checks succeed immediately.
await _db.updateFilePath(id, sibling);
pathById[id] = sibling;
return MapEntry(id, true);
}
return MapEntry(id, false);
} catch (e) {
_historyLog.w('Error checking file existence for $id: $e');
return MapEntry(id, false);
@@ -904,11 +974,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
StreamSubscription<Map<String, dynamic>>? _progressStreamSub;
int _downloadCount = 0;
static const _cleanupInterval = 50;
static const _progressPollingInterval = Duration(milliseconds: 800);
static const _progressPollingInterval = Duration(milliseconds: 1200);
static const _idleProgressPollEveryTicks = 3;
static const _queueSchedulingInterval = Duration(milliseconds: 250);
static const _queuePersistDebounceDuration = Duration(milliseconds: 350);
static const _bytesUiStep = 104857; // ~0.1 MiB, matches one-decimal MB UI.
static const _serviceProgressStepPercent = 2;
final NotificationService _notificationService = NotificationService();
final AppStateDatabase _appStateDb = AppStateDatabase.instance;
int _totalQueuedAtStart = 0;
@@ -1445,12 +1516,17 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
.round()
.clamp(0, 100)
.toInt();
final progressBucket = progressPercent == 100
? 100
: ((progressPercent ~/ _serviceProgressStepPercent) *
_serviceProgressStepPercent)
.clamp(0, 100);
final didContentChange =
trackName != _lastServiceTrackName ||
artistName != _lastServiceArtistName ||
queueCount != _lastServiceQueueCount ||
progressPercent != _lastServicePercent;
progressBucket != _lastServicePercent;
final allowHeartbeat =
now.difference(_lastServiceUpdateAt) >= const Duration(seconds: 5);
@@ -1460,7 +1536,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_lastServiceTrackName = trackName;
_lastServiceArtistName = artistName;
_lastServicePercent = progressPercent;
_lastServicePercent = progressBucket;
_lastServiceQueueCount = queueCount;
_lastServiceUpdateAt = now;
@@ -1809,7 +1885,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return '.opus';
}
if (service.toLowerCase() == 'tidal' && quality == 'HIGH') {
return '.m4a';
return '.flac'; // HIGH quality no longer available; fallback to FLAC
}
return '.flac';
}
@@ -1912,7 +1988,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
);
}
String addToQueue(Track track, String service, {String? qualityOverride, String? playlistName}) {
String addToQueue(
Track track,
String service, {
String? qualityOverride,
String? playlistName,
}) {
final settings = ref.read(settingsProvider);
updateSettings(settings);
@@ -2337,11 +2418,26 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final backendAlbum = normalizeOptionalString(
backendResult['album'] as String?,
);
final backendIsrc = normalizeOptionalString(
backendResult['isrc'] as String?,
);
final backendCoverUrl = normalizeOptionalString(
backendResult['cover_url'] as String?,
);
final backendAlbumArtist = normalizeOptionalString(
backendResult['album_artist'] as String?,
);
if (backendTrackNum == null &&
backendDiscNum == null &&
backendYear == null &&
backendAlbum == null) {
final hasOverrides =
backendTrackNum != null ||
backendDiscNum != null ||
backendYear != null ||
backendAlbum != null ||
backendIsrc != null ||
backendCoverUrl != null ||
backendAlbumArtist != null;
if (!hasOverrides) {
return baseTrack;
}
@@ -2350,12 +2446,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
name: baseTrack.name,
artistName: baseTrack.artistName,
albumName: backendAlbum ?? baseTrack.albumName,
albumArtist: resolvedAlbumArtist,
albumArtist: backendAlbumArtist ?? resolvedAlbumArtist,
artistId: baseTrack.artistId,
albumId: baseTrack.albumId,
coverUrl: baseTrack.coverUrl,
coverUrl: backendCoverUrl ?? baseTrack.coverUrl,
duration: baseTrack.duration,
isrc: baseTrack.isrc,
isrc: backendIsrc ?? baseTrack.isrc,
trackNumber: backendTrackNum ?? baseTrack.trackNumber,
discNumber: backendDiscNum ?? baseTrack.discNumber,
releaseDate: backendYear ?? baseTrack.releaseDate,
@@ -3562,6 +3658,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}',
);
}
if (!useSaf) {
await _ensureDirExists(outputDir, label: 'Output folder');
}
_log.d('Output dir: $outputDir');
final normalizedTrackNumber =
@@ -3575,10 +3676,26 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
? trackToDownload.discNumber!
: 1;
String payloadSpotifyId = trackToDownload.id;
String payloadQobuzId = '';
String payloadTidalId = '';
if (trackToDownload.id.startsWith('qobuz:')) {
payloadQobuzId = trackToDownload.id.substring(6);
if (item.service == 'qobuz') {
payloadSpotifyId = '';
}
}
if (trackToDownload.id.startsWith('tidal:')) {
payloadTidalId = trackToDownload.id.substring(6);
if (item.service == 'tidal') {
payloadSpotifyId = '';
}
}
final payload = DownloadRequestPayload(
isrc: trackToDownload.isrc ?? '',
service: item.service,
spotifyId: trackToDownload.id,
spotifyId: payloadSpotifyId,
trackName: trackToDownload.name,
artistName: trackToDownload.artistName,
albumName: trackToDownload.albumName,
@@ -3602,6 +3719,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
genre: genre ?? '',
label: label ?? '',
copyright: copyright ?? '',
qobuzId: payloadQobuzId,
tidalId: payloadTidalId,
deezerId: deezerTrackId ?? '',
lyricsMode: settings.lyricsMode,
storageMode: storageMode,
@@ -3835,7 +3954,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
isContentUriPath &&
effectiveSafMode &&
actualService == 'tidal' &&
quality != 'HIGH' &&
filePath.endsWith('.flac') &&
(mimeType == null || mimeType.contains('flac'));
@@ -3850,73 +3968,50 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final currentFilePath = filePath;
if (isContentUriPath && effectiveSafMode) {
if (quality == 'HIGH') {
final tidalHighFormat = settings.tidalHighFormat;
_log.i(
'Tidal HIGH quality (SAF), converting M4A to $tidalHighFormat...',
);
final tempPath = await _copySafToTemp(currentFilePath);
if (tempPath != null) {
String? convertedPath;
try {
_log.d('M4A file detected (SAF), converting to FLAC...');
final tempPath = await _copySafToTemp(currentFilePath);
if (tempPath != null) {
String? flacPath;
try {
final length = await File(tempPath).length();
if (length < 1024) {
_log.w('Temp M4A is too small (<1KB), skipping conversion');
} else {
updateItemStatus(
item.id,
DownloadStatus.downloading,
progress: 0.95,
);
final format = tidalHighFormat.startsWith('opus')
? 'opus'
: 'mp3';
convertedPath = await FFmpegService.convertM4aToLossy(
tempPath,
format: format,
bitrate: tidalHighFormat,
deleteOriginal: false,
);
if (convertedPath != null) {
_log.i(
'Successfully converted M4A to $format (temp): $convertedPath',
);
_log.i('Embedding metadata to $format...');
updateItemStatus(
item.id,
DownloadStatus.downloading,
progress: 0.99,
flacPath = await FFmpegService.convertM4aToFlac(tempPath);
if (flacPath != null) {
_log.d('Converted to FLAC (temp): $flacPath');
_log.d('Embedding metadata and cover to converted FLAC...');
final finalTrack = _buildTrackForMetadataEmbedding(
trackToDownload,
result,
resolvedAlbumArtist,
);
final backendGenre = result['genre'] as String?;
final backendLabel = result['label'] as String?;
final backendCopyright = result['copyright'] as String?;
if (format == 'mp3') {
await _embedMetadataToMp3(
convertedPath,
trackToDownload,
genre: backendGenre ?? genre,
label: backendLabel ?? label,
copyright: backendCopyright,
);
} else {
await _embedMetadataToOpus(
convertedPath,
trackToDownload,
genre: backendGenre ?? genre,
label: backendLabel ?? label,
copyright: backendCopyright,
);
}
await _embedMetadataAndCover(
flacPath,
finalTrack,
genre: backendGenre ?? genre,
label: backendLabel ?? label,
copyright: backendCopyright,
writeExternalLrc: false,
);
final newExt = format == 'opus' ? '.opus' : '.mp3';
final newFileName = '${safBaseName ?? 'track'}$newExt';
final newFileName = '${safBaseName ?? 'track'}.flac';
final newUri = await _writeTempToSaf(
treeUri: settings.downloadTreeUri,
relativeDir: effectiveOutputDir,
fileName: newFileName,
mimeType: _mimeTypeForExt(newExt),
srcPath: convertedPath,
mimeType: _mimeTypeForExt('.flac'),
srcPath: flacPath,
);
if (newUri != null) {
@@ -3925,58 +4020,60 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
filePath = newUri;
finalSafFileName = newFileName;
final bitrateDisplay = tidalHighFormat.contains('_')
? '${tidalHighFormat.split('_').last}kbps'
: '320kbps';
actualQuality = '${format.toUpperCase()} $bitrateDisplay';
} else {
_log.w(
'Failed to write converted $format to SAF, keeping M4A',
);
actualQuality = 'AAC 320kbps';
_log.w('Failed to write FLAC to SAF, keeping M4A');
}
} else {
_log.w(
'M4A to $format conversion failed, keeping M4A file',
);
actualQuality = 'AAC 320kbps';
}
} catch (e) {
_log.w('SAF M4A conversion failed: $e');
actualQuality = 'AAC 320kbps';
} finally {
// Clean up temp files
try {
await File(tempPath).delete();
} catch (_) {}
if (convertedPath != null) {
try {
await File(convertedPath).delete();
} catch (_) {}
_log.w('FFmpeg conversion returned null, keeping M4A file');
}
}
}
} else {
_log.d('M4A file detected (SAF), converting to FLAC...');
final tempPath = await _copySafToTemp(currentFilePath);
if (tempPath != null) {
String? flacPath;
} catch (e) {
_log.w('SAF M4A->FLAC conversion failed: $e');
} finally {
// Clean up temp files
try {
final length = await File(tempPath).length();
if (length < 1024) {
_log.w('Temp M4A is too small (<1KB), skipping conversion');
} else {
updateItemStatus(
item.id,
DownloadStatus.downloading,
progress: 0.95,
);
flacPath = await FFmpegService.convertM4aToFlac(tempPath);
if (flacPath != null) {
_log.d('Converted to FLAC (temp): $flacPath');
_log.d(
'Embedding metadata and cover to converted FLAC...',
);
await File(tempPath).delete();
} catch (_) {}
if (flacPath != null) {
try {
await File(flacPath).delete();
} catch (_) {}
}
}
}
} else {
_log.d(
'M4A file detected (Hi-Res DASH stream), attempting conversion to FLAC...',
);
try {
final file = File(currentFilePath);
if (!await file.exists()) {
_log.e('File does not exist at path: $filePath');
} else {
final length = await file.length();
_log.i('File size before conversion: ${length / 1024} KB');
if (length < 1024) {
_log.w(
'File is too small (<1KB), skipping conversion. Download might be corrupt.',
);
} else {
updateItemStatus(
item.id,
DownloadStatus.downloading,
progress: 0.95,
);
final flacPath = await FFmpegService.convertM4aToFlac(
currentFilePath,
);
if (flacPath != null) {
filePath = flacPath;
_log.d('Converted to FLAC: $flacPath');
_log.d('Embedding metadata and cover to converted FLAC...');
try {
final finalTrack = _buildTrackForMetadataEmbedding(
trackToDownload,
result,
@@ -3987,201 +4084,32 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final backendLabel = result['label'] as String?;
final backendCopyright = result['copyright'] as String?;
if (backendGenre != null ||
backendLabel != null ||
backendCopyright != null) {
_log.d(
'Extended metadata from backend - Genre: $backendGenre, Label: $backendLabel, Copyright: $backendCopyright',
);
}
await _embedMetadataAndCover(
flacPath,
finalTrack,
genre: backendGenre ?? genre,
label: backendLabel ?? label,
copyright: backendCopyright,
writeExternalLrc: false,
);
final newFileName = '${safBaseName ?? 'track'}.flac';
final newUri = await _writeTempToSaf(
treeUri: settings.downloadTreeUri,
relativeDir: effectiveOutputDir,
fileName: newFileName,
mimeType: _mimeTypeForExt('.flac'),
srcPath: flacPath,
);
if (newUri != null) {
if (newUri != currentFilePath) {
await _deleteSafFile(currentFilePath);
}
filePath = newUri;
finalSafFileName = newFileName;
} else {
_log.w('Failed to write FLAC to SAF, keeping M4A');
}
} else {
_log.w(
'FFmpeg conversion returned null, keeping M4A file',
);
_log.d('Metadata and cover embedded successfully');
} catch (e) {
_log.w('Warning: Failed to embed metadata/cover: $e');
}
}
} catch (e) {
_log.w('SAF M4A->FLAC conversion failed: $e');
} finally {
// Clean up temp files
try {
await File(tempPath).delete();
} catch (_) {}
if (flacPath != null) {
try {
await File(flacPath).delete();
} catch (_) {}
}
}
}
}
} else {
if (quality == 'HIGH') {
final tidalHighFormat = settings.tidalHighFormat;
_log.i(
'Tidal HIGH quality download, converting M4A to $tidalHighFormat...',
);
try {
updateItemStatus(
item.id,
DownloadStatus.downloading,
progress: 0.95,
);
final format = tidalHighFormat.startsWith('opus')
? 'opus'
: 'mp3';
final convertedPath = await FFmpegService.convertM4aToLossy(
currentFilePath,
format: format,
bitrate: tidalHighFormat,
deleteOriginal: true,
);
if (convertedPath != null) {
filePath = convertedPath;
final bitrateDisplay = tidalHighFormat.contains('_')
? '${tidalHighFormat.split('_').last}kbps'
: '320kbps';
actualQuality = '${format.toUpperCase()} $bitrateDisplay';
_log.i(
'Successfully converted M4A to $format: $convertedPath',
);
_log.i('Embedding metadata to $format...');
updateItemStatus(
item.id,
DownloadStatus.downloading,
progress: 0.99,
);
final backendGenre = result['genre'] as String?;
final backendLabel = result['label'] as String?;
final backendCopyright = result['copyright'] as String?;
if (format == 'mp3') {
await _embedMetadataToMp3(
convertedPath,
trackToDownload,
genre: backendGenre ?? genre,
label: backendLabel ?? label,
copyright: backendCopyright,
);
} else {
await _embedMetadataToOpus(
convertedPath,
trackToDownload,
genre: backendGenre ?? genre,
label: backendLabel ?? label,
copyright: backendCopyright,
);
}
_log.d('Metadata embedded successfully');
} else {
_log.w('M4A to $format conversion failed, keeping M4A file');
actualQuality = 'AAC 320kbps';
}
} catch (e) {
_log.w('M4A conversion process failed: $e, keeping M4A file');
actualQuality = 'AAC 320kbps';
}
} else {
_log.d(
'M4A file detected (Hi-Res DASH stream), attempting conversion to FLAC...',
);
try {
final file = File(currentFilePath);
if (!await file.exists()) {
_log.e('File does not exist at path: $filePath');
} else {
final length = await file.length();
_log.i('File size before conversion: ${length / 1024} KB');
if (length < 1024) {
_log.w(
'File is too small (<1KB), skipping conversion. Download might be corrupt.',
);
} else {
updateItemStatus(
item.id,
DownloadStatus.downloading,
progress: 0.95,
);
final flacPath = await FFmpegService.convertM4aToFlac(
currentFilePath,
);
if (flacPath != null) {
filePath = flacPath;
_log.d('Converted to FLAC: $flacPath');
_log.d(
'Embedding metadata and cover to converted FLAC...',
);
try {
final finalTrack = _buildTrackForMetadataEmbedding(
trackToDownload,
result,
resolvedAlbumArtist,
);
final backendGenre = result['genre'] as String?;
final backendLabel = result['label'] as String?;
final backendCopyright = result['copyright'] as String?;
if (backendGenre != null ||
backendLabel != null ||
backendCopyright != null) {
_log.d(
'Extended metadata from backend - Genre: $backendGenre, Label: $backendLabel, Copyright: $backendCopyright',
);
}
await _embedMetadataAndCover(
flacPath,
finalTrack,
genre: backendGenre ?? genre,
label: backendLabel ?? label,
copyright: backendCopyright,
);
_log.d('Metadata and cover embedded successfully');
} catch (e) {
_log.w('Warning: Failed to embed metadata/cover: $e');
}
} else {
_log.w(
'FFmpeg conversion returned null, keeping M4A file',
);
}
_log.w('FFmpeg conversion returned null, keeping M4A file');
}
}
} catch (e) {
_log.w(
'FFmpeg conversion process failed: $e, keeping M4A file',
);
}
} catch (e) {
_log.w('FFmpeg conversion process failed: $e, keeping M4A file');
}
}
} else if (metadataEmbeddingEnabled &&
+5 -3
View File
@@ -892,7 +892,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
List<String> getAllMetadataProviders() {
final providers = ['deezer'];
final providers = ['deezer', 'qobuz', 'tidal'];
for (final ext in state.extensions) {
if (ext.enabled && ext.hasMetadataProvider) {
providers.add(ext.id);
@@ -911,8 +911,10 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
}
if (!result.contains('deezer')) {
result.insert(0, 'deezer');
for (final provider in const ['deezer', 'qobuz', 'tidal']) {
if (!result.contains(provider)) {
result.add(provider);
}
}
return result;
+36 -16
View File
@@ -120,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;
@@ -315,7 +315,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;
@@ -379,18 +378,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) {
@@ -399,7 +421,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>?) ??
@@ -465,7 +486,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
}
}
// Delete removed items
if (deletedPaths.isNotEmpty) {
final deleteCount = await _db.deleteByPaths(deletedPaths);
for (final path in deletedPaths) {
+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({
+28 -7
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 = 5;
const _currentMigrationVersion = 6;
const _spotifyClientSecretKey = 'spotify_client_secret';
final _log = AppLogger('SettingsProvider');
class SettingsNotifier extends Notifier<AppSettings> {
static const List<int> _youtubeOpusSupportedBitrates = [128, 256];
static const List<int> _youtubeOpusSupportedBitrates = [128, 256, 320];
static const List<int> _youtubeMp3SupportedBitrates = [128, 256, 320];
static final RegExp _isoRegionPattern = RegExp(r'^[A-Z]{2}$');
@@ -37,6 +39,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
state = AppSettings.fromJson(jsonDecode(json));
await _runMigrations(prefs);
await _normalizeIosDownloadDirectoryIfNeeded();
await _normalizeYouTubeBitratesIfNeeded();
await _normalizeSongLinkRegionIfNeeded();
}
@@ -114,6 +117,10 @@ class SettingsNotifier extends Notifier<AppSettings> {
useCustomSpotifyCredentials: false,
);
}
// Migration 6: Tidal HIGH quality removed migrate to LOSSLESS
if (state.audioQuality == 'HIGH') {
state = state.copyWith(audioQuality: 'LOSSLESS');
}
state = state.copyWith(lastSeenVersion: AppInfo.version);
await prefs.setInt(_migrationVersionKey, _currentMigrationVersion);
await _saveSettings();
@@ -189,6 +196,20 @@ class SettingsNotifier extends Notifier<AppSettings> {
await _saveSettings();
}
Future<void> _normalizeIosDownloadDirectoryIfNeeded() async {
if (!Platform.isIOS) return;
final currentDir = state.downloadDirectory.trim();
if (currentDir.isEmpty) return;
final normalizedDir = await validateOrFixIosPath(currentDir);
if (normalizedDir == currentDir) return;
_log.i('Normalized iOS download directory: $currentDir -> $normalizedDir');
state = state.copyWith(downloadDirectory: normalizedDir);
await _saveSettings();
}
String _normalizeSongLinkRegion(String region) {
final normalized = region.trim().toUpperCase();
if (_isoRegionPattern.hasMatch(normalized)) return normalized;
@@ -430,11 +451,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setTidalHighFormat(String format) {
state = state.copyWith(tidalHighFormat: format);
_saveSettings();
}
void setYoutubeOpusBitrate(int bitrate) {
final normalized = _normalizeYouTubeOpusBitrate(bitrate);
state = state.copyWith(youtubeOpusBitrate: normalized);
@@ -502,6 +518,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);
+163 -112
View File
@@ -18,7 +18,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
@@ -384,6 +384,76 @@ class TrackNotifier extends Notifier<TrackState> {
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: albumInfo['images'] as String?,
);
_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 =
(playlistInfo['images'] ?? owner?['images']) as String?;
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: artistInfo['images'] as String?,
artistAlbums: albums,
);
}
return;
}
if (url.contains('tidal.com')) {
_log.i('Detected Tidal URL, parsing...');
final parsed = await PlatformBridge.parseTidalUrl(url);
@@ -392,68 +462,65 @@ 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: albumInfo['images'] as String?,
);
_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 =
(playlistInfo['images'] ?? owner?['images']) as String?;
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: artistInfo['images'] as String?,
artistAlbums: albums,
);
}
// For album/artist/playlist, not yet supported
state = TrackState(
isLoading: false,
error:
'Tidal $type links are not fully supported yet. Only track links work via SongLink conversion.',
hasSearchText: state.hasSearchText,
);
return;
}
@@ -515,11 +582,15 @@ 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 =
(playlistInfo['images'] ?? owner?['images']) as String?;
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') {
@@ -566,41 +637,31 @@ 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;
const source = 'deezer';
final includeExtensions =
settings.useExtensionProviders && hasActiveMetadataExtensions;
_log.i(
'Search started: source=$source, query="$query", useExtensions=$useExtensions, filter=$currentFilter',
'Search started: metadataProviders, query="$query", includeExtensions=$includeExtensions, filter=$currentFilter',
);
Map<String, dynamic> results;
List<Track> extensionTracks = [];
List<Map<String, dynamic>> metadataTrackResults = [];
if (useExtensions) {
try {
_log.d('Calling extension search API...');
final extResults = await PlatformBridge.searchTracksWithExtensions(
query,
limit: 20,
);
_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 Deezer: $e');
}
try {
_log.d('Calling metadata provider search API...');
metadataTrackResults =
await PlatformBridge.searchTracksWithMetadataProviders(
query,
limit: 20,
includeExtensions: includeExtensions,
);
_log.i(
'Metadata providers returned ${metadataTrackResults.length} tracks',
);
} catch (e) {
_log.w(
'Metadata provider search failed, falling back to Deezer tracks: $e',
);
}
_log.d('Calling Deezer search API...');
@@ -622,32 +683,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);
}
@@ -697,7 +746,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(
@@ -884,8 +933,10 @@ 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? ?? '',
+82 -27
View File
@@ -81,16 +81,20 @@ 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 +133,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
return (mediaSize.height * 0.55).clamp(360.0, 520.0);
}
/// Upgrade cover URL to a reasonable resolution for full-screen display.
/// Spotify CDN only has 300, 640, ~2000 we stay at 640 (no intermediate).
/// Deezer CDN: upgrade to 1000x1000 (available: 56, 250, 500, 1000, 1400, 1800).
/// Upgrade cover URL to a higher resolution for full-screen display.
String? _highResCoverUrl(String? url) {
if (url == null) return null;
// Spotify CDN: upgrade 300 640 only (no intermediate between 640 and 2000)
@@ -175,6 +177,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);
@@ -272,7 +280,10 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
) {
final expandedHeight = _calculateExpandedHeight(context);
final tracks = _tracks ?? [];
final artistName = tracks.isNotEmpty ? tracks.first.artistName : null;
final artistName = widget.artistName ??
(tracks.isNotEmpty
? (tracks.first.albumArtist ?? tracks.first.artistName)
: null);
final releaseDate = tracks.isNotEmpty ? tracks.first.releaseDate : null;
return SliverAppBar(
@@ -505,7 +516,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
}
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
// Info is now displayed in the full-screen cover overlay
return const SliverToBoxAdapter(child: SizedBox.shrink());
}
@@ -560,37 +570,74 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
void _downloadAll(BuildContext context) {
final tracks = _tracks;
if (tracks == null || tracks.isEmpty) return;
// Skip already-downloaded tracks
final historyState = ref.read(downloadHistoryProvider);
final settings = ref.read(settingsProvider);
final localLibState = (settings.localLibraryEnabled && settings.localLibraryShowDuplicates)
? ref.read(localLibraryProvider)
: null;
final tracksToQueue = <Track>[];
int skippedCount = 0;
for (final track in tracks) {
final isInHistory = historyState.isDownloaded(track.id) ||
(track.isrc != null && historyState.getByIsrc(track.isrc!) != null) ||
historyState.findByTrackAndArtist(track.name, track.artistName) != null;
final isInLocal = localLibState?.existsInLibrary(
isrc: track.isrc,
trackName: track.name,
artistName: track.artistName,
) ??
false;
if (isInHistory || isInLocal) {
skippedCount++;
} else {
tracksToQueue.add(track);
}
}
if (tracksToQueue.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.discographySkippedDownloaded(0, skippedCount),
),
),
);
return;
}
if (settings.askQualityBeforeDownload) {
DownloadServicePicker.show(
context,
trackName: '${tracks.length} tracks',
trackName: '${tracksToQueue.length} tracks',
artistName: widget.albumName,
onSelect: (quality, service) {
ref
.read(downloadQueueProvider.notifier)
.addMultipleToQueue(tracks, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarAddedTracksToQueue(tracks.length),
),
),
);
.addMultipleToQueue(tracksToQueue, service, qualityOverride: quality);
_showQueuedSnackbar(context, tracksToQueue.length, skippedCount);
},
);
} else {
ref
.read(downloadQueueProvider.notifier)
.addMultipleToQueue(tracks, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)),
),
);
.addMultipleToQueue(tracksToQueue, settings.defaultService);
_showQueuedSnackbar(context, tracksToQueue.length, skippedCount);
}
}
void _showQueuedSnackbar(BuildContext context, int added, int skipped) {
final message = skipped > 0
? context.l10n.discographySkippedDownloaded(added, skipped)
: context.l10n.snackbarAddedTracksToQueue(added);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
}
Widget _buildLoveAllButton() {
final collectionsState = ref.watch(libraryCollectionsProvider);
final tracks = _tracks;
@@ -619,7 +666,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,
),
);
@@ -642,7 +691,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,
),
);
@@ -660,7 +709,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 {
@@ -673,7 +726,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)),
),
);
}
}
+185 -16
View File
@@ -38,12 +38,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 +56,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 +64,7 @@ class _CacheEntry {
_CacheEntry({
required this.albums,
this.releases,
this.topTracks,
this.headerImageUrl,
this.monthlyListeners,
@@ -97,6 +101,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 +109,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 +160,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 +181,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 +202,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 +227,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
_popularPageController.dispose();
super.dispose();
}
@@ -216,6 +235,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 +250,66 @@ 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;
@@ -303,8 +385,11 @@ 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'] ?? album?.name ?? '')
@@ -323,20 +408,28 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
releaseDate: data['release_date']?.toString(),
albumType: data['album_type']?.toString() ?? album?.albumType,
totalTracks: data['total_tracks'] as int? ?? album?.totalTracks,
source: data['provider_id']?.toString(),
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: (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,
);
}
@@ -359,6 +452,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;
@@ -404,6 +498,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(
@@ -961,6 +1063,24 @@ 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);
@@ -1211,7 +1331,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,
@@ -1225,11 +1347,58 @@ 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),
),
);
}),
),
),
),
],
);
}
+140 -53
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,44 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
BuildContext context,
List<DownloadHistoryItem> allTracks,
) {
String selectedFormat = 'MP3';
String selectedBitrate = '320k';
final tracksById = {for (final t in allTracks) t.id: t};
final sourceFormats = <String>{};
for (final id in _selectedIds) {
final item = tracksById[id];
if (item == null) continue;
final nameToCheck =
(item.safFileName != null && item.safFileName!.isNotEmpty)
? item.safFileName!.toLowerCase()
: item.filePath.toLowerCase();
final ext = nameToCheck.endsWith('.flac')
? 'FLAC'
: nameToCheck.endsWith('.m4a')
? 'M4A'
: nameToCheck.endsWith('.mp3')
? 'MP3'
: (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg'))
? 'Opus'
: null;
if (ext != null) sourceFormats.add(ext);
}
final formats = ['ALAC', 'FLAC', 'MP3', 'Opus'].where((target) {
return sourceFormats.any((src) {
if (src == target) return false;
final isLosslessTarget = target == 'ALAC' || target == 'FLAC';
final isLosslessSource = src == 'FLAC' || src == 'M4A';
if (isLosslessTarget && !isLosslessSource) return false;
return true;
});
}).toList();
if (formats.isEmpty) return;
String selectedFormat = formats.first;
bool isLosslessTarget =
selectedFormat == 'ALAC' || selectedFormat == 'FLAC';
String selectedBitrate =
isLosslessTarget ? '320k' : (selectedFormat == 'Opus' ? '128k' : '320k');
showModalBottomSheet(
context: context,
@@ -924,7 +959,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
return StatefulBuilder(
builder: (context, setSheetState) {
final colorScheme = Theme.of(context).colorScheme;
final formats = ['MP3', 'Opus'];
final bitrates = ['128k', '192k', '256k', '320k'];
return SafeArea(
@@ -961,51 +995,75 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
),
),
const SizedBox(height: 8),
Row(
children: formats.map((format) {
final isSelected = format == selectedFormat;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(format),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setSheetState(() {
selectedFormat = format;
selectedBitrate = format == 'Opus'
? '128k'
: '320k';
});
}
},
),
);
}).toList(),
),
const SizedBox(height: 16),
Text(
context.l10n.trackConvertBitrate,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: bitrates.map((br) {
final isSelected = br == selectedBitrate;
children: formats.map((format) {
final isSelected = format == selectedFormat;
return ChoiceChip(
label: Text(br),
label: Text(format),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setSheetState(() => selectedBitrate = br);
setSheetState(() {
selectedFormat = format;
isLosslessTarget =
format == 'ALAC' || format == 'FLAC';
if (!isLosslessTarget) {
selectedBitrate =
format == 'Opus' ? '128k' : '320k';
}
});
}
},
);
}).toList(),
),
if (!isLosslessTarget) ...[
const SizedBox(height: 16),
Text(
context.l10n.trackConvertBitrate,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: bitrates.map((br) {
final isSelected = br == selectedBitrate;
return ChoiceChip(
label: Text(br),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setSheetState(() => selectedBitrate = br);
}
},
);
}).toList(),
),
],
if (isLosslessTarget) ...[
const SizedBox(height: 16),
Row(
children: [
Icon(
Icons.verified,
size: 16,
color: colorScheme.primary,
),
const SizedBox(width: 6),
Text(
context.l10n.trackConvertLosslessHint,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(
color: colorScheme.primary,
),
),
],
),
],
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
@@ -1058,12 +1116,19 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
: item.filePath.toLowerCase();
final ext = nameToCheck.endsWith('.flac')
? 'FLAC'
: nameToCheck.endsWith('.m4a')
? 'M4A'
: nameToCheck.endsWith('.mp3')
? 'MP3'
: (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg'))
? 'Opus'
: null;
if (ext != null && ext != targetFormat) selected.add(item);
if (ext == null || ext == targetFormat) continue;
// Skip lossy sources when target is lossless (pointless re-encoding)
final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC';
final isLosslessSource = ext == 'FLAC' || ext == 'M4A';
if (isLosslessTarget && !isLosslessSource) continue;
selected.add(item);
}
if (selected.isEmpty) {
@@ -1075,16 +1140,22 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
return;
}
final isLossless = targetFormat == 'ALAC' || targetFormat == 'FLAC';
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(context.l10n.selectionBatchConvertConfirmTitle),
content: Text(
context.l10n.selectionBatchConvertConfirmMessage(
selected.length,
targetFormat,
bitrate,
),
isLossless
? context.l10n.selectionBatchConvertConfirmMessageLossless(
selected.length,
targetFormat,
)
: context.l10n.selectionBatchConvertConfirmMessage(
selected.length,
targetFormat,
bitrate,
),
),
actions: [
TextButton(
@@ -1104,8 +1175,10 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
int successCount = 0;
final total = selected.length;
final historyDb = HistoryDatabase.instance;
final newQuality =
'${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}';
final newQuality = (targetFormat.toUpperCase() == 'ALAC' ||
targetFormat.toUpperCase() == 'FLAC')
? '${targetFormat.toUpperCase()} Lossless'
: '${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}';
final settings = ref.read(settingsProvider);
final shouldEmbedLyrics =
settings.embedLyrics && settings.lyricsMode != 'external';
@@ -1208,13 +1281,27 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
final baseName = dotIdx > 0
? oldFileName.substring(0, dotIdx)
: oldFileName;
final newExt = targetFormat.toLowerCase() == 'opus'
? '.opus'
: '.mp3';
String newExt;
String mimeType;
switch (targetFormat.toLowerCase()) {
case 'opus':
newExt = '.opus';
mimeType = 'audio/opus';
break;
case 'alac':
newExt = '.m4a';
mimeType = 'audio/mp4';
break;
case 'flac':
newExt = '.flac';
mimeType = 'audio/flac';
break;
default:
newExt = '.mp3';
mimeType = 'audio/mpeg';
break;
}
final newFileName = '$baseName$newExt';
final mimeType = targetFormat.toLowerCase() == 'opus'
? 'audio/opus'
: 'audio/mpeg';
final safUri = await PlatformBridge.createSafFileFromPath(
treeUri: treeUri,
+40 -11
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';
@@ -81,6 +82,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 +196,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),
);
@@ -816,7 +846,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(() {
@@ -987,9 +1017,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 =
@@ -1750,7 +1778,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);
@@ -1822,9 +1850,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)),
);
}
}
@@ -3881,6 +3909,7 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
name: (data['name'] ?? '').toString(),
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
albumName: (data['album_name'] ?? widget.albumName).toString(),
albumArtist: (data['album_artist'] ?? _artistName)?.toString(),
artistId:
(data['artist_id'] ?? data['artistId'])?.toString() ?? _artistId,
albumId: data['album_id']?.toString() ?? widget.albumId,
@@ -39,6 +39,7 @@ class _LibraryTracksFolderScreenState
bool _isSelectionMode = false;
final Set<String> _selectedKeys = {};
UserPlaylistCollection? playlist;
@override
void initState() {
@@ -243,7 +244,6 @@ class _LibraryTracksFolderScreenState
final colorScheme = Theme.of(context).colorScheme;
ref.watch(localLibraryProvider.select((s) => s.items));
final localState = ref.read(localLibraryProvider);
final UserPlaylistCollection? playlist;
final List<CollectionTrackEntry> entries;
switch (widget.mode) {
@@ -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),
),
],
);
@@ -873,6 +873,7 @@ class _LibraryTracksFolderScreenState
void _downloadAll(List<Track> tracks) {
if (tracks.isEmpty) return;
final settings = ref.read(settingsProvider);
final playlistName = widget.mode == LibraryTracksFolderMode.playlist ? playlist?.name ?? context.l10n.collectionPlaylist : null;
if (settings.askQualityBeforeDownload) {
DownloadServicePicker.show(
context,
@@ -885,7 +886,7 @@ class _LibraryTracksFolderScreenState
onSelect: (quality, service) {
ref
.read(downloadQueueProvider.notifier)
.addMultipleToQueue(tracks, service, qualityOverride: quality);
.addMultipleToQueue(tracks, service, qualityOverride: quality, playlistName: playlistName);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@@ -899,7 +900,7 @@ class _LibraryTracksFolderScreenState
} else {
ref
.read(downloadQueueProvider.notifier)
.addMultipleToQueue(tracks, settings.defaultService);
.addMultipleToQueue(tracks, settings.defaultService, playlistName: playlistName);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)),
+288 -55
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';
@@ -41,11 +45,10 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
void _showCueVirtualTrackSnackBar() {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(cueVirtualTrackRequiresSplitMessage),
),
const SnackBar(content: Text(cueVirtualTrackRequiresSplitMessage)),
);
}
late List<int> _sortedDiscNumbersCache;
late bool _hasMultipleDiscsCache;
String? _commonQualityCache;
@@ -247,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)),
);
}
@@ -897,6 +900,127 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
return false;
}
Future<void> _queueSelectedAsFlac(List<LocalLibraryItem> allTracks) async {
final tracksById = {for (final t in allTracks) t.id: t};
final selected = <LocalLibraryItem>[];
for (final id in _selectedIds) {
final item = tracksById[id];
if (item != null) {
selected.add(item);
}
}
if (selected.isEmpty) {
return;
}
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(context.l10n.queueFlacAction),
content: Text(context.l10n.queueFlacConfirmMessage(selected.length)),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text(context.l10n.queueFlacAction),
),
],
),
);
if (confirmed != true || !mounted) {
return;
}
final settings = ref.read(settingsProvider);
final extensionState = ref.read(extensionProvider);
final includeExtensions =
settings.useExtensionProviders &&
extensionState.extensions.any(
(ext) => ext.enabled && ext.hasMetadataProvider,
);
final targetService = LocalTrackRedownloadService.preferredFlacService(
settings,
);
final targetQuality =
LocalTrackRedownloadService.preferredFlacQualityForService(
targetService,
);
final matchedTracks = <Track>[];
var skippedCount = 0;
final total = selected.length;
for (var i = 0; i < total; i++) {
if (!mounted) break;
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.queueFlacFindingProgress(i + 1, total),
),
duration: const Duration(seconds: 30),
),
);
try {
final resolution = await LocalTrackRedownloadService.resolveBestMatch(
selected[i],
includeExtensions: includeExtensions,
);
if (resolution.canQueue && resolution.match != null) {
matchedTracks.add(resolution.match!);
} else {
skippedCount++;
}
} catch (_) {
skippedCount++;
}
}
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).clearSnackBars();
if (matchedTracks.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.queueFlacNoReliableMatches)),
);
return;
}
ref
.read(downloadQueueProvider.notifier)
.addMultipleToQueue(
matchedTracks,
targetService,
qualityOverride: targetQuality,
);
final summary = skippedCount == 0
? context.l10n.snackbarAddedTracksToQueue(matchedTracks.length)
: context.l10n.queueFlacQueuedWithSkipped(
matchedTracks.length,
skippedCount,
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(summary)));
setState(() {
_selectedIds.clear();
_isSelectionMode = false;
});
}
Future<void> _reEnrichSelected(List<LocalLibraryItem> allTracks) async {
final tracksById = {for (final t in allTracks) t.id: t};
final selected = <LocalLibraryItem>[];
@@ -1005,8 +1129,56 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
BuildContext context,
List<LocalLibraryItem> allTracks,
) {
String selectedFormat = 'MP3';
String selectedBitrate = '320k';
final tracksById = {for (final t in allTracks) t.id: t};
final sourceFormats = <String>{};
for (final id in _selectedIds) {
final item = tracksById[id];
if (item == null) continue;
String? ext;
if (item.format != null && item.format!.isNotEmpty) {
final fmt = item.format!.toLowerCase();
if (fmt == 'flac') {
ext = 'FLAC';
} else if (fmt == 'm4a') {
ext = 'M4A';
} else if (fmt == 'mp3') {
ext = 'MP3';
} else if (fmt == 'opus' || fmt == 'ogg') {
ext = 'Opus';
}
}
if (ext == null) {
final lower = item.filePath.toLowerCase();
if (lower.endsWith('.flac')) {
ext = 'FLAC';
} else if (lower.endsWith('.m4a')) {
ext = 'M4A';
} else if (lower.endsWith('.mp3')) {
ext = 'MP3';
} else if (lower.endsWith('.opus') || lower.endsWith('.ogg')) {
ext = 'Opus';
}
}
if (ext != null) sourceFormats.add(ext);
}
final formats = ['ALAC', 'FLAC', 'MP3', 'Opus'].where((target) {
return sourceFormats.any((src) {
if (src == target) return false;
final isLosslessTarget = target == 'ALAC' || target == 'FLAC';
final isLosslessSource = src == 'FLAC' || src == 'M4A';
if (isLosslessTarget && !isLosslessSource) return false;
return true;
});
}).toList();
if (formats.isEmpty) return;
String selectedFormat = formats.first;
bool isLosslessTarget =
selectedFormat == 'ALAC' || selectedFormat == 'FLAC';
String selectedBitrate =
isLosslessTarget ? '320k' : (selectedFormat == 'Opus' ? '128k' : '320k');
showModalBottomSheet(
context: context,
@@ -1018,7 +1190,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
return StatefulBuilder(
builder: (context, setSheetState) {
final colorScheme = Theme.of(context).colorScheme;
final formats = ['MP3', 'Opus'];
final bitrates = ['128k', '192k', '256k', '320k'];
return SafeArea(
@@ -1055,51 +1226,75 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
),
),
const SizedBox(height: 8),
Row(
children: formats.map((format) {
final isSelected = format == selectedFormat;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(format),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setSheetState(() {
selectedFormat = format;
selectedBitrate = format == 'Opus'
? '128k'
: '320k';
});
}
},
),
);
}).toList(),
),
const SizedBox(height: 16),
Text(
context.l10n.trackConvertBitrate,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: bitrates.map((br) {
final isSelected = br == selectedBitrate;
children: formats.map((format) {
final isSelected = format == selectedFormat;
return ChoiceChip(
label: Text(br),
label: Text(format),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setSheetState(() => selectedBitrate = br);
setSheetState(() {
selectedFormat = format;
isLosslessTarget =
format == 'ALAC' || format == 'FLAC';
if (!isLosslessTarget) {
selectedBitrate =
format == 'Opus' ? '128k' : '320k';
}
});
}
},
);
}).toList(),
),
if (!isLosslessTarget) ...[
const SizedBox(height: 16),
Text(
context.l10n.trackConvertBitrate,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: bitrates.map((br) {
final isSelected = br == selectedBitrate;
return ChoiceChip(
label: Text(br),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setSheetState(() => selectedBitrate = br);
}
},
);
}).toList(),
),
],
if (isLosslessTarget) ...[
const SizedBox(height: 16),
Row(
children: [
Icon(
Icons.verified,
size: 16,
color: colorScheme.primary,
),
const SizedBox(width: 6),
Text(
context.l10n.trackConvertLosslessHint,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(
color: colorScheme.primary,
),
),
],
),
],
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
@@ -1152,6 +1347,8 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
final fmt = item.format!.toLowerCase();
if (fmt == 'flac') {
currentFormat = 'FLAC';
} else if (fmt == 'm4a') {
currentFormat = 'M4A';
} else if (fmt == 'mp3') {
currentFormat = 'MP3';
} else if (fmt == 'opus' || fmt == 'ogg') {
@@ -1163,15 +1360,20 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
final lower = item.filePath.toLowerCase();
if (lower.endsWith('.flac')) {
currentFormat = 'FLAC';
} else if (lower.endsWith('.m4a')) {
currentFormat = 'M4A';
} else if (lower.endsWith('.mp3')) {
currentFormat = 'MP3';
} else if (lower.endsWith('.opus') || lower.endsWith('.ogg')) {
currentFormat = 'Opus';
}
}
if (currentFormat != null && currentFormat != targetFormat) {
selected.add(item);
}
if (currentFormat == null || currentFormat == targetFormat) continue;
// Skip lossy sources when target is lossless (pointless re-encoding)
final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC';
final isLosslessSource = currentFormat == 'FLAC' || currentFormat == 'M4A';
if (isLosslessTarget && !isLosslessSource) continue;
selected.add(item);
}
if (selected.isEmpty) {
@@ -1183,16 +1385,22 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
return;
}
final isLossless = targetFormat == 'ALAC' || targetFormat == 'FLAC';
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(context.l10n.selectionBatchConvertConfirmTitle),
content: Text(
context.l10n.selectionBatchConvertConfirmMessage(
selected.length,
targetFormat,
bitrate,
),
isLossless
? context.l10n.selectionBatchConvertConfirmMessageLossless(
selected.length,
targetFormat,
)
: context.l10n.selectionBatchConvertConfirmMessage(
selected.length,
targetFormat,
bitrate,
),
),
actions: [
TextButton(
@@ -1357,13 +1565,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,
@@ -1525,6 +1747,17 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
Row(
children: [
Expanded(
child: _LocalAlbumSelectionActionButton(
icon: Icons.download_for_offline_outlined,
label: '${context.l10n.queueFlacAction} ($selectedCount)',
onPressed: selectedCount > 0
? () => _queueSelectedAsFlac(tracks)
: null,
colorScheme: colorScheme,
),
),
const SizedBox(width: 8),
Expanded(
child: _LocalAlbumSelectionActionButton(
icon: Icons.auto_fix_high_outlined,
+48 -26
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;
@@ -113,17 +113,18 @@ class _MainShellState extends ConsumerState<MainShell> {
if (trackState.error != null && mounted) {
final l10n = context.l10n;
final errorMsg = trackState.error!;
final isRateLimit = errorMsg.contains('429') ||
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)),
);
? l10n.errorRateLimitedMessage
: l10n.errorUrlFetchFailed;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(displayMessage)));
}
}
@@ -158,12 +159,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);
@@ -181,25 +180,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 {
@@ -219,15 +213,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),
),
],
),
@@ -260,6 +252,7 @@ class _MainShellState extends ConsumerState<MainShell> {
}
if (_currentIndex != index) {
final shouldResetHome = index == 0;
HapticFeedback.selectionClick();
setState(() => _currentIndex = index);
final showStore = ref.read(
@@ -269,6 +262,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),
@@ -508,11 +505,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),
@@ -573,6 +574,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});
+114 -38
View File
@@ -39,8 +39,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 +69,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 +96,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) {
@@ -184,7 +199,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 +221,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 +270,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.playlistName,
_playlistName,
style: const TextStyle(
color: Colors.white,
fontSize: 24,
@@ -336,7 +350,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
}
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
// Info is now displayed in the full-screen cover overlay
return const SliverToBoxAdapter(child: SizedBox.shrink());
}
@@ -416,7 +429,12 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
onSelect: (quality, service) {
ref
.read(downloadQueueProvider.notifier)
.addToQueue(track, service, qualityOverride: quality, playlistName: widget.playlistName);
.addToQueue(
track,
service,
qualityOverride: quality,
playlistName: _playlistName,
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarAddedToQueue(track.name)),
@@ -427,7 +445,11 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
} else {
ref
.read(downloadQueueProvider.notifier)
.addToQueue(track, settings.defaultService, playlistName: widget.playlistName);
.addToQueue(
track,
settings.defaultService,
playlistName: _playlistName,
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))),
);
@@ -482,7 +504,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 +529,10 @@ 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 +544,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 +556,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
Navigator.pop(dialogContext);
_downloadAll(context);
},
child: const Text('Download'),
child: Text(context.l10n.dialogDownload),
),
],
);
@@ -552,7 +576,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 +593,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 +607,82 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
void _downloadTracks(BuildContext context, List<Track> tracks) {
if (tracks.isEmpty) return;
// Skip already-downloaded tracks
final historyState = ref.read(downloadHistoryProvider);
final settings = ref.read(settingsProvider);
final localLibState = (settings.localLibraryEnabled && settings.localLibraryShowDuplicates)
? ref.read(localLibraryProvider)
: null;
final tracksToQueue = <Track>[];
int skippedCount = 0;
for (final track in tracks) {
final isInHistory = historyState.isDownloaded(track.id) ||
(track.isrc != null && historyState.getByIsrc(track.isrc!) != null) ||
historyState.findByTrackAndArtist(track.name, track.artistName) != null;
final isInLocal = localLibState?.existsInLibrary(
isrc: track.isrc,
trackName: track.name,
artistName: track.artistName,
) ??
false;
if (isInHistory || isInLocal) {
skippedCount++;
} else {
tracksToQueue.add(track);
}
}
if (tracksToQueue.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.discographySkippedDownloaded(0, skippedCount),
),
),
);
return;
}
if (settings.askQualityBeforeDownload) {
DownloadServicePicker.show(
context,
trackName: '${tracks.length} tracks',
artistName: widget.playlistName,
trackName: '${tracksToQueue.length} tracks',
artistName: _playlistName,
onSelect: (quality, service) {
ref
.read(downloadQueueProvider.notifier)
.addMultipleToQueue(tracks, service, qualityOverride: quality, playlistName: widget.playlistName);
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, playlistName: widget.playlistName);
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
+760 -384
View File
File diff suppressed because it is too large Load Diff
+2 -1
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';
@@ -52,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
+2 -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,
@@ -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),
),
+3 -3
View File
@@ -164,7 +164,7 @@ class _RecentDonorsCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
const donorNames = <String>['a fan'];
const donorNames = <String>['micahRichie', 'a fan', 'mc nuggets jimmy', 'CJBGR'];
// Match SettingsGroup color logic
final cardColor = isDark
@@ -479,8 +479,8 @@ int _cr(String v) {
return r;
}
// Highlighted supporters (hashes of names): none for now.
const _cv = <int>{};
// Highlighted supporters (hashes of names).
const _cv = <int>{1211573191};
class _SupporterChip extends StatelessWidget {
final String name;
+82 -203
View File
@@ -300,7 +300,6 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
final topPadding = normalizedHeaderTopPadding(context);
final isBuiltInService = _builtInServices.contains(settings.defaultService);
final isTidalService = settings.defaultService == 'tidal';
return PopScope(
canPop: true,
@@ -376,7 +375,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
title: context.l10n.downloadAskBeforeDownload,
subtitle: isBuiltInService
? context.l10n.downloadAskQualitySubtitle
: 'Select a built-in service to enable',
: context.l10n.downloadSelectServiceToEnable,
value: settings.askQualityBeforeDownload,
enabled: isBuiltInService,
onChanged: (value) => ref
@@ -408,35 +407,8 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
onTap: () => ref
.read(settingsProvider.notifier)
.setAudioQuality('HI_RES_LOSSLESS'),
showDivider: isTidalService,
showDivider: false,
),
// Lossy 320kbps option (Tidal only) - downloads M4A, converts to MP3/Opus
if (isTidalService)
_QualityOption(
title: 'Lossy 320kbps',
subtitle: _getTidalHighFormatLabel(
settings.tidalHighFormat,
),
isSelected: settings.audioQuality == 'HIGH',
onTap: () => ref
.read(settingsProvider.notifier)
.setAudioQuality('HIGH'),
showDivider: false,
),
if (isTidalService && settings.audioQuality == 'HIGH')
SettingsItem(
icon: Icons.tune,
title: 'Lossy Format',
subtitle: _getTidalHighFormatLabel(
settings.tidalHighFormat,
),
onTap: () => _showTidalHighFormatPicker(
context,
ref,
settings.tidalHighFormat,
),
showDivider: false,
),
],
if (!isBuiltInService) ...[
Padding(
@@ -451,7 +423,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
const SizedBox(width: 8),
Expanded(
child: Text(
'Select Tidal or Qobuz above to configure quality',
context.l10n.downloadSelectTidalQobuz,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: colorScheme.onSurfaceVariant,
@@ -464,12 +436,12 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
],
SettingsItem(
title: context.l10n.youtubeOpusBitrateTitle,
subtitle: '${settings.youtubeOpusBitrate}kbps (128/256)',
subtitle: '${settings.youtubeOpusBitrate}kbps (128/256/320)',
onTap: () => _showYoutubeBitratePicker(
context: context,
title: context.l10n.youtubeOpusBitrateTitle,
currentValue: settings.youtubeOpusBitrate,
options: const [128, 256],
options: const [128, 256, 320],
onSave: (value) => ref
.read(settingsProvider.notifier)
.setYoutubeOpusBitrate(value),
@@ -504,7 +476,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
title: context.l10n.optionsEmbedLyrics,
subtitle: settings.embedMetadata
? context.l10n.optionsEmbedLyricsSubtitle
: 'Disabled while Embed Metadata is turned off',
: context.l10n.downloadEmbedLyricsDisabled,
value: settings.embedLyrics,
enabled: settings.embedMetadata,
onChanged: (value) => ref
@@ -528,7 +500,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
),
SettingsItem(
icon: Icons.source_outlined,
title: 'Lyrics Providers',
title: context.l10n.lyricsProvidersTitle,
subtitle: _getLyricsProvidersSubtitle(
settings.lyricsProviders,
),
@@ -541,10 +513,10 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
),
SettingsSwitchItem(
icon: Icons.translate_outlined,
title: 'Netease: Include Translation',
title: context.l10n.downloadNeteaseIncludeTranslation,
subtitle: settings.lyricsIncludeTranslationNetease
? 'Append translated lyrics when available'
: 'Use original lyrics only',
? context.l10n.downloadNeteaseIncludeTranslationEnabled
: context.l10n.downloadNeteaseIncludeTranslationDisabled,
value: settings.lyricsIncludeTranslationNetease,
onChanged: (value) => ref
.read(settingsProvider.notifier)
@@ -552,10 +524,10 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
),
SettingsSwitchItem(
icon: Icons.text_fields_outlined,
title: 'Netease: Include Romanization',
title: context.l10n.downloadNeteaseIncludeRomanization,
subtitle: settings.lyricsIncludeRomanizationNetease
? 'Append romanized lyrics when available'
: 'Disabled',
? context.l10n.downloadNeteaseIncludeRomanizationEnabled
: context.l10n.downloadNeteaseIncludeRomanizationDisabled,
value: settings.lyricsIncludeRomanizationNetease,
onChanged: (value) => ref
.read(settingsProvider.notifier)
@@ -563,10 +535,10 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
),
SettingsSwitchItem(
icon: Icons.record_voice_over_outlined,
title: 'Apple/QQ Multi-Person Word-by-Word',
title: context.l10n.downloadAppleQqMultiPerson,
subtitle: settings.lyricsMultiPersonWordByWord
? 'Enable v1/v2 speaker and [bg:] tags'
: 'Simplified word-by-word formatting',
? context.l10n.downloadAppleQqMultiPersonEnabled
: context.l10n.downloadAppleQqMultiPersonDisabled,
value: settings.lyricsMultiPersonWordByWord,
onChanged: (value) => ref
.read(settingsProvider.notifier)
@@ -574,9 +546,9 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
),
SettingsItem(
icon: Icons.language_outlined,
title: 'Musixmatch Language',
title: context.l10n.downloadMusixmatchLanguage,
subtitle: settings.musixmatchLanguage.isEmpty
? 'Auto (original)'
? context.l10n.downloadMusixmatchLanguageAuto
: settings.musixmatchLanguage.toUpperCase(),
onTap: () => _showMusixmatchLanguagePicker(
context,
@@ -622,8 +594,8 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
icon: Icons.library_music_outlined,
title: context.l10n.downloadSeparateSinglesFolder,
subtitle: settings.separateSingles
? 'Albums/ and Singles/ folders'
: 'All files in same structure',
? context.l10n.downloadSeparateSinglesEnabled
: context.l10n.downloadSeparateSinglesDisabled,
value: settings.separateSingles,
onChanged: (value) => ref
.read(settingsProvider.notifier)
@@ -670,9 +642,9 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
.read(settingsProvider.notifier)
.setUseAlbumArtistForFolders(value),
),
SettingsItem(
SettingsItem(
icon: Icons.filter_alt_outlined,
title: 'Artist Name Filters',
title: context.l10n.downloadArtistNameFilters,
subtitle: _getArtistFolderFilterSubtitle(
context,
usePrimaryArtistOnly: settings.usePrimaryArtistOnly,
@@ -707,28 +679,16 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
if (_artistFolderFiltersExpanded)
SettingsSwitchItem(
icon: Icons.group_remove_outlined,
title: 'Filter contributing artists in Album Artist',
title: context.l10n.downloadFilterContributing,
subtitle: settings.filterContributingArtistsInAlbumArtist
? 'Album Artist metadata uses primary artist only'
: 'Keep full Album Artist metadata value',
? context.l10n.downloadFilterContributingEnabled
: context.l10n.downloadFilterContributingDisabled,
value: settings.filterContributingArtistsInAlbumArtist,
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setFilterContributingArtistsInAlbumArtist(value),
showDivider: false,
),
SettingsSwitchItem(
icon: Icons.person_outline,
title: context.l10n.downloadUsePrimaryArtistOnly,
subtitle: settings.usePrimaryArtistOnly
? context.l10n.downloadUsePrimaryArtistOnlyEnabled
: context.l10n.downloadUsePrimaryArtistOnlyDisabled,
value: settings.usePrimaryArtistOnly,
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setUsePrimaryArtistOnly(value),
showDivider: false,
),
],
),
),
@@ -753,7 +713,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
),
SettingsItem(
icon: Icons.public,
title: 'SongLink Region',
title: context.l10n.downloadSongLinkRegion,
subtitle: _getSongLinkRegionLabel(settings.songLinkRegion),
onTap: () => _showSongLinkRegionPicker(
context,
@@ -763,10 +723,10 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
),
SettingsSwitchItem(
icon: Icons.security_outlined,
title: 'Network compatibility mode',
title: context.l10n.downloadNetworkCompatibilityMode,
subtitle: settings.networkCompatibilityMode
? 'Enabled: try HTTP + accept invalid TLS certificates (unsafe)'
: 'Off: strict HTTPS certificate validation (recommended)',
? context.l10n.downloadNetworkCompatibilityModeEnabled
: context.l10n.downloadNetworkCompatibilityModeDisabled,
value: settings.networkCompatibilityMode,
onChanged: (value) {
ref
@@ -1045,7 +1005,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
),
const SizedBox(height: 8),
Text(
'Customize how your files are named.',
context.l10n.downloadFilenameDescription,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -1070,7 +1030,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
const SizedBox(height: 24),
Text(
'Tap to insert tag:',
context.l10n.downloadFilenameInsertTag,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
@@ -1238,7 +1198,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
'Download Location',
context.l10n.setupDownloadLocationTitle,
style: Theme.of(
ctx,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
@@ -1247,7 +1207,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
'Choose storage mode for downloaded files.',
context.l10n.downloadLocationSubtitle,
style: Theme.of(ctx).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -1255,8 +1215,8 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
),
ListTile(
leading: Icon(Icons.folder_special, color: colorScheme.primary),
title: const Text('App folder (non-SAF)'),
subtitle: const Text('Use default Music/SpotiFLAC path'),
title: Text(context.l10n.storageModeAppFolder),
subtitle: Text(context.l10n.storageModeAppFolderSubtitle),
trailing: !isSafMode ? const Icon(Icons.check) : null,
onTap: () async {
Navigator.pop(ctx);
@@ -1269,10 +1229,8 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
),
ListTile(
leading: Icon(Icons.folder_open, color: colorScheme.primary),
title: const Text('SAF folder'),
subtitle: const Text(
'Pick folder via Android Storage Access Framework',
),
title: Text(context.l10n.storageModeSaf),
subtitle: Text(context.l10n.storageModeSafSubtitle),
trailing: isSafMode ? const Icon(Icons.check) : null,
onTap: () async {
Navigator.pop(ctx);
@@ -1352,8 +1310,27 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
subtitle: Text(context.l10n.setupChooseFromFilesSubtitle),
onTap: () async {
Navigator.pop(ctx);
if (Platform.isIOS) {
await Future<void>.delayed(const Duration(milliseconds: 250));
}
// Note: iOS requires folder to have at least one file to be selectable
final result = await FilePicker.platform.getDirectoryPath();
String? result;
try {
result = await FilePicker.platform.getDirectoryPath();
} catch (e) {
if (ctx.mounted) {
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(
content: Text('Failed to open folder picker: $e'),
backgroundColor: Theme.of(ctx).colorScheme.error,
duration: const Duration(seconds: 4),
),
);
}
return;
}
if (result != null) {
// iOS: Validate the selected path is writable (not iCloud or container root)
if (Platform.isIOS) {
@@ -1546,7 +1523,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
};
String _getLyricsProvidersSubtitle(List<String> providers) {
if (providers.isEmpty) return 'None enabled';
if (providers.isEmpty) return context.l10n.downloadProvidersNoneEnabled;
return providers.map((p) => _providerDisplayNames[p] ?? p).join(' > ');
}
@@ -1645,14 +1622,14 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Musixmatch Language',
context.l10n.downloadMusixmatchLanguage,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
'Set preferred language code (example: en, es, ja). Leave empty for auto.',
context.l10n.downloadMusixmatchLanguageDesc,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -1661,9 +1638,9 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
TextField(
controller: controller,
textInputAction: TextInputAction.done,
decoration: const InputDecoration(
labelText: 'Language code',
hintText: 'auto / en / es / ja',
decoration: InputDecoration(
labelText: context.l10n.downloadMusixmatchLanguageCode,
hintText: context.l10n.downloadMusixmatchLanguageHint,
),
),
const SizedBox(height: 16),
@@ -1682,7 +1659,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
.setMusixmatchLanguage('');
Navigator.pop(context);
},
child: const Text('Auto'),
child: Text(context.l10n.downloadMusixmatchAuto),
),
const SizedBox(width: 8),
FilledButton(
@@ -1705,104 +1682,6 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
);
}
String _getTidalHighFormatLabel(String format) {
switch (format) {
case 'mp3_320':
return 'MP3 320kbps';
case 'opus_256':
return 'Opus 256kbps';
case 'opus_128':
return 'Opus 128kbps';
default:
return 'MP3 320kbps';
}
}
void _showTidalHighFormatPicker(
BuildContext context,
WidgetRef ref,
String current,
) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
'Lossy 320kbps Format',
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
ListTile(
leading: const Icon(Icons.audiotrack),
title: const Text('MP3 320kbps'),
subtitle: const Text('Best compatibility, ~10MB per track'),
trailing: current == 'mp3_320'
? Icon(Icons.check, color: colorScheme.primary)
: null,
onTap: () {
ref
.read(settingsProvider.notifier)
.setTidalHighFormat('mp3_320');
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.graphic_eq),
title: const Text('Opus 256kbps'),
subtitle: const Text('Best quality Opus, ~8MB per track'),
trailing: current == 'opus_256'
? Icon(Icons.check, color: colorScheme.primary)
: null,
onTap: () {
ref
.read(settingsProvider.notifier)
.setTidalHighFormat('opus_256');
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.graphic_eq),
title: const Text('Opus 128kbps'),
subtitle: const Text('Smallest size, ~4MB per track'),
trailing: current == 'opus_128'
? Icon(Icons.check, color: colorScheme.primary)
: null,
onTap: () {
ref
.read(settingsProvider.notifier)
.setTidalHighFormat('opus_128');
Navigator.pop(context);
},
),
const SizedBox(height: 16),
],
),
),
);
}
void _showNetworkModePicker(
BuildContext context,
WidgetRef ref,
@@ -1842,7 +1721,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
ListTile(
leading: const Icon(Icons.signal_cellular_alt),
title: Text(context.l10n.settingsDownloadNetworkAny),
subtitle: const Text('WiFi + Mobile Data'),
subtitle: Text(context.l10n.downloadNetworkAnySubtitle),
trailing: current == 'any'
? Icon(Icons.check, color: colorScheme.primary)
: null,
@@ -1856,7 +1735,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
ListTile(
leading: const Icon(Icons.wifi),
title: Text(context.l10n.settingsDownloadNetworkWifiOnly),
subtitle: const Text('Pause downloads on mobile data'),
subtitle: Text(context.l10n.downloadNetworkWifiOnlySubtitle),
trailing: current == 'wifi_only'
? Icon(Icons.check, color: colorScheme.primary)
: null,
@@ -1897,17 +1776,17 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
'SongLink Region',
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
child: Text(
context.l10n.downloadSongLinkRegion,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
'Used as userCountry for SongLink API lookup.',
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
context.l10n.downloadSongLinkRegionDesc,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -1968,12 +1847,12 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
'Folder Organization',
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
child: Text(
context.l10n.downloadFolderOrganization,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
@@ -801,7 +801,7 @@ class _SettingItemState extends State<_SettingItem> {
Future<void> _invokeAction(BuildContext context) async {
if (widget.setting.action == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No action defined for this button')),
SnackBar(content: Text(context.l10n.snackbarNoActionDefined)),
);
return;
}
@@ -834,7 +834,7 @@ class _SettingItemState extends State<_SettingItem> {
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Error: $e')));
).showSnackBar(SnackBar(content: Text(context.l10n.snackbarError(e.toString()))));
}
} finally {
if (mounted) {
+133 -1
View File
@@ -241,6 +241,99 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
}
}
String _getAutoScanLabel(BuildContext context, String mode) {
switch (mode) {
case 'on_open':
return context.l10n.libraryAutoScanOnOpen;
case 'daily':
return context.l10n.libraryAutoScanDaily;
case 'weekly':
return context.l10n.libraryAutoScanWeekly;
default:
return context.l10n.libraryAutoScanOff;
}
}
void _showAutoScanPicker(BuildContext context, String current) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
context.l10n.libraryAutoScan,
style: Theme.of(context)
.textTheme
.titleLarge
?.copyWith(fontWeight: FontWeight.bold),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
context.l10n.libraryAutoScanSubtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
_AutoScanOption(
icon: Icons.block,
title: context.l10n.libraryAutoScanOff,
selected: current == 'off',
colorScheme: colorScheme,
onTap: () {
ref.read(settingsProvider.notifier).setLocalLibraryAutoScan('off');
Navigator.pop(context);
},
),
_AutoScanOption(
icon: Icons.open_in_new,
title: context.l10n.libraryAutoScanOnOpen,
selected: current == 'on_open',
colorScheme: colorScheme,
onTap: () {
ref.read(settingsProvider.notifier).setLocalLibraryAutoScan('on_open');
Navigator.pop(context);
},
),
_AutoScanOption(
icon: Icons.today,
title: context.l10n.libraryAutoScanDaily,
selected: current == 'daily',
colorScheme: colorScheme,
onTap: () {
ref.read(settingsProvider.notifier).setLocalLibraryAutoScan('daily');
Navigator.pop(context);
},
),
_AutoScanOption(
icon: Icons.date_range,
title: context.l10n.libraryAutoScanWeekly,
selected: current == 'weekly',
colorScheme: colorScheme,
onTap: () {
ref.read(settingsProvider.notifier).setLocalLibraryAutoScan('weekly');
Navigator.pop(context);
},
),
const SizedBox(height: 16),
],
),
),
);
}
@override
Widget build(BuildContext context) {
final settings = ref.watch(settingsProvider);
@@ -344,7 +437,18 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setLocalLibraryShowDuplicates(value),
showDivider: false,
),
Opacity(
opacity: settings.localLibraryEnabled ? 1.0 : 0.5,
child: SettingsItem(
icon: Icons.autorenew_rounded,
title: context.l10n.libraryAutoScan,
subtitle: _getAutoScanLabel(context, settings.localLibraryAutoScan),
onTap: settings.localLibraryEnabled
? () => _showAutoScanPicker(context, settings.localLibraryAutoScan)
: null,
showDivider: false,
),
),
],
),
@@ -825,3 +929,31 @@ class _ScanProgressTile extends StatelessWidget {
);
}
}
class _AutoScanOption extends StatelessWidget {
final IconData icon;
final String title;
final bool selected;
final ColorScheme colorScheme;
final VoidCallback onTap;
const _AutoScanOption({
required this.icon,
required this.title,
required this.selected,
required this.colorScheme,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return ListTile(
leading: Icon(icon),
title: Text(title),
trailing: selected
? Icon(Icons.check, color: colorScheme.primary)
: null,
onTap: onTap,
);
}
}
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/widgets/priority_settings_scaffold.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
@@ -55,18 +56,18 @@ class _LyricsProviderPriorityPageState
return PrioritySettingsScaffold(
hasChanges: _hasChanges,
title: 'Lyrics Providers',
description:
'Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.',
infoText:
'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.',
title: context.l10n.lyricsProvidersTitle,
description: context.l10n.lyricsProvidersDescription,
infoText: context.l10n.lyricsProvidersInfoText,
onSave: _saveChanges,
onConfirmDiscard: _confirmDiscard,
slivers: [
if (_enabledProviders.isNotEmpty)
SliverToBoxAdapter(
child: SettingsSectionHeader(
title: 'Enabled (${_enabledProviders.length})',
title: context.l10n.lyricsProvidersEnabledSection(
_enabledProviders.length,
),
),
),
if (_enabledProviders.isNotEmpty)
@@ -76,7 +77,7 @@ class _LyricsProviderPriorityPageState
itemCount: _enabledProviders.length,
itemBuilder: (context, index) {
final id = _enabledProviders[index];
final info = _getLyricsProviderInfo(id);
final info = _getLyricsProviderInfo(id, context);
return _EnabledProviderItem(
key: ValueKey(id),
providerId: id,
@@ -99,7 +100,9 @@ class _LyricsProviderPriorityPageState
if (disabled.isNotEmpty)
SliverToBoxAdapter(
child: SettingsSectionHeader(
title: 'Disabled (${disabled.length})',
title: context.l10n.lyricsProvidersDisabledSection(
disabled.length,
),
),
),
if (disabled.isNotEmpty)
@@ -108,7 +111,7 @@ class _LyricsProviderPriorityPageState
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final id = disabled[index];
final info = _getLyricsProviderInfo(id);
final info = _getLyricsProviderInfo(id, context);
return _DisabledProviderItem(
key: ValueKey(id),
providerId: id,
@@ -130,8 +133,8 @@ class _LyricsProviderPriorityPageState
void _disableProvider(String id) {
if (_enabledProviders.length <= 1) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('At least one provider must remain enabled'),
SnackBar(
content: Text(context.l10n.lyricsProvidersAtLeastOne),
),
);
return;
@@ -150,7 +153,7 @@ class _LyricsProviderPriorityPageState
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Lyrics provider priority saved')),
SnackBar(content: Text(context.l10n.lyricsProvidersSaved)),
);
}
}
@@ -159,16 +162,16 @@ class _LyricsProviderPriorityPageState
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Discard changes?'),
content: const Text('You have unsaved changes that will be lost.'),
title: Text(context.l10n.dialogDiscardChanges),
content: Text(context.l10n.lyricsProvidersDiscardContent),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Discard'),
child: Text(context.l10n.dialogDiscard),
),
],
),
@@ -176,48 +179,51 @@ class _LyricsProviderPriorityPageState
return result ?? false;
}
static _LyricsProviderInfo _getLyricsProviderInfo(String id) {
static _LyricsProviderInfo _getLyricsProviderInfo(
String id,
BuildContext context,
) {
switch (id) {
case 'spotify_api':
return _LyricsProviderInfo(
name: 'Spotify Lyrics API',
description: 'Spotify-sourced synced lyrics via community API',
description: context.l10n.lyricsProviderSpotifyApiDesc,
icon: Icons.music_note_outlined,
);
case 'lrclib':
return _LyricsProviderInfo(
name: 'LRCLIB',
description: 'Open-source synced lyrics database',
description: context.l10n.lyricsProviderLrclibDesc,
icon: Icons.subtitles_outlined,
);
case 'netease':
return _LyricsProviderInfo(
name: 'Netease',
description: 'NetEase Cloud Music (good for Asian songs)',
description: context.l10n.lyricsProviderNeteaseDesc,
icon: Icons.cloud_outlined,
);
case 'musixmatch':
return _LyricsProviderInfo(
name: 'Musixmatch',
description: 'Largest lyrics database (multi-language)',
description: context.l10n.lyricsProviderMusixmatchDesc,
icon: Icons.translate,
);
case 'apple_music':
return _LyricsProviderInfo(
name: 'Apple Music',
description: 'Word-by-word synced lyrics (via proxy)',
description: context.l10n.lyricsProviderAppleMusicDesc,
icon: Icons.music_note,
);
case 'qqmusic':
return _LyricsProviderInfo(
name: 'QQ Music',
description: 'QQ Music (good for Chinese songs, via proxy)',
description: context.l10n.lyricsProviderQqMusicDesc,
icon: Icons.queue_music,
);
default:
return _LyricsProviderInfo(
name: id,
description: 'Extension provider',
description: context.l10n.lyricsProviderExtensionDesc,
icon: Icons.extension,
);
}
@@ -228,6 +228,20 @@ class _MetadataProviderItem extends StatelessWidget {
description: context.l10n.metadataNoRateLimits,
isBuiltIn: true,
);
case 'qobuz':
return _MetadataProviderInfo(
name: 'Qobuz',
icon: Icons.library_music,
description: context.l10n.providerBuiltIn,
isBuiltIn: true,
);
case 'tidal':
return _MetadataProviderInfo(
name: 'Tidal',
icon: Icons.music_note,
description: context.l10n.providerBuiltIn,
isBuiltIn: true,
);
default:
return _MetadataProviderInfo(
name: provider,
@@ -334,6 +334,12 @@ class _ProviderItem extends StatelessWidget {
);
case 'qobuz':
return _ProviderInfo(name: 'Qobuz', icon: Icons.album, isBuiltIn: true);
case 'deezer':
return _ProviderInfo(
name: 'Deezer',
icon: Icons.graphic_eq,
isBuiltIn: true,
);
case 'youtube':
return _ProviderInfo(
name: 'YouTube',
+3 -3
View File
@@ -107,8 +107,8 @@ class SettingsTab extends ConsumerWidget {
),
SettingsItem(
icon: Icons.favorite_outline,
title: 'Donate',
subtitle: 'Support SpotiFLAC-Mobile development',
title: l10n.settingsDonate,
subtitle: l10n.settingsDonateSubtitle,
onTap: () => _navigateTo(context, const DonatePage()),
showDivider: false,
),
@@ -133,7 +133,7 @@ class SettingsTab extends ConsumerWidget {
SettingsItem(
icon: Icons.info_outline,
title: l10n.settingsAbout,
subtitle: '${l10n.aboutVersion} ${AppInfo.version}',
subtitle: '${l10n.aboutVersion} ${AppInfo.displayVersion}',
onTap: () => _navigateTo(context, const AboutPage()),
showDivider: false,
),
+20 -1
View File
@@ -321,7 +321,26 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
title: Text(context.l10n.setupChooseFromFiles),
onTap: () async {
Navigator.pop(ctx);
final result = await FilePicker.platform.getDirectoryPath();
if (Platform.isIOS) {
await Future<void>.delayed(const Duration(milliseconds: 250));
}
String? result;
try {
result = await FilePicker.platform.getDirectoryPath();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to open folder picker: $e'),
backgroundColor: Theme.of(context).colorScheme.error,
duration: const Duration(seconds: 4),
),
);
}
return;
}
if (result != null) {
// iOS: Validate the selected path is writable
if (Platform.isIOS) {
+313 -138
View File
@@ -16,6 +16,7 @@ class StoreTab extends ConsumerStatefulWidget {
class _StoreTabState extends ConsumerState<StoreTab> {
final _searchController = TextEditingController();
final _repoUrlController = TextEditingController();
bool _isInitialized = false;
@override
@@ -38,6 +39,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
@override
void dispose() {
_searchController.dispose();
_repoUrlController.dispose();
super.dispose();
}
@@ -56,6 +58,8 @@ class _StoreTabState extends ConsumerState<StoreTab> {
final downloadingId = ref.watch(
storeProvider.select((s) => s.downloadingId),
);
final hasRegistryUrl = ref.watch(storeProvider.select((s) => s.hasRegistryUrl));
final registryUrl = ref.watch(storeProvider.select((s) => s.registryUrl));
final filteredExtensions = StoreState(
extensions: extensions,
selectedCategory: selectedCategory,
@@ -84,6 +88,14 @@ class _StoreTabState extends ConsumerState<StoreTab> {
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
automaticallyImplyLeading: false,
actions: [
if (hasRegistryUrl)
IconButton(
icon: const Icon(Icons.link),
tooltip: context.l10n.storeChangeRepoTooltip,
onPressed: () => _showChangeRepoDialog(registryUrl),
),
],
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
@@ -109,151 +121,154 @@ class _StoreTabState extends ConsumerState<StoreTab> {
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: ValueListenableBuilder<TextEditingValue>(
valueListenable: _searchController,
builder: (context, value, _) {
return TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: context.l10n.storeSearch,
prefixIcon: const Icon(Icons.search),
suffixIcon: value.text.isNotEmpty
? IconButton(
tooltip: 'Clear search',
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
ref
.read(storeProvider.notifier)
.setSearchQuery('');
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(28),
borderSide: BorderSide.none,
),
filled: true,
fillColor:
Theme.of(context).brightness == Brightness.dark
? Color.alphaBlend(
Colors.white.withValues(alpha: 0.08),
colorScheme.surface,
)
: colorScheme.surfaceContainerHighest,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
onChanged: (value) {
ref.read(storeProvider.notifier).setSearchQuery(value);
},
);
},
),
),
),
SliverToBoxAdapter(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Row(
children: [
_CategoryChip(
label: context.l10n.storeFilterAll,
icon: Icons.apps,
isSelected: selectedCategory == null,
onTap: () =>
ref.read(storeProvider.notifier).setCategory(null),
),
const SizedBox(width: 8),
_CategoryChip(
label: context.l10n.storeFilterMetadata,
icon: Icons.label_outline,
isSelected: selectedCategory == StoreCategory.metadata,
onTap: () => ref
.read(storeProvider.notifier)
.setCategory(StoreCategory.metadata),
),
const SizedBox(width: 8),
_CategoryChip(
label: context.l10n.storeFilterDownload,
icon: Icons.download_outlined,
isSelected: selectedCategory == StoreCategory.download,
onTap: () => ref
.read(storeProvider.notifier)
.setCategory(StoreCategory.download),
),
const SizedBox(width: 8),
_CategoryChip(
label: context.l10n.storeFilterUtility,
icon: Icons.build_outlined,
isSelected: selectedCategory == StoreCategory.utility,
onTap: () => ref
.read(storeProvider.notifier)
.setCategory(StoreCategory.utility),
),
const SizedBox(width: 8),
_CategoryChip(
label: context.l10n.storeFilterLyrics,
icon: Icons.lyrics_outlined,
isSelected: selectedCategory == StoreCategory.lyrics,
onTap: () => ref
.read(storeProvider.notifier)
.setCategory(StoreCategory.lyrics),
),
const SizedBox(width: 8),
_CategoryChip(
label: context.l10n.storeFilterIntegration,
icon: Icons.link,
isSelected: selectedCategory == StoreCategory.integration,
onTap: () => ref
.read(storeProvider.notifier)
.setCategory(StoreCategory.integration),
),
],
),
),
),
if (isLoading && extensions.isEmpty)
const SliverFillRemaining(
child: Center(child: CircularProgressIndicator()),
)
else if (error != null && extensions.isEmpty)
SliverFillRemaining(child: _buildErrorState(error, colorScheme))
else if (filteredExtensions.isEmpty)
if (!hasRegistryUrl)
SliverFillRemaining(
child: _buildEmptyState(
hasFilters:
searchQuery.isNotEmpty || selectedCategory != null,
colorScheme: colorScheme,
),
child: _buildSetupRepoState(colorScheme, error),
)
else ...[
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text(
'${filteredExtensions.length} ${filteredExtensions.length == 1 ? 'extension' : 'extensions'}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
child: ValueListenableBuilder<TextEditingValue>(
valueListenable: _searchController,
builder: (context, value, _) {
return TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: context.l10n.storeSearch,
prefixIcon: const Icon(Icons.search),
suffixIcon: value.text.isNotEmpty
? IconButton(
tooltip: 'Clear search',
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
ref
.read(storeProvider.notifier)
.setSearchQuery('');
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(28),
borderSide: BorderSide.none,
),
filled: true,
fillColor:
Theme.of(context).brightness == Brightness.dark
? Color.alphaBlend(
Colors.white.withValues(alpha: 0.08),
colorScheme.surface,
)
: colorScheme.surfaceContainerHighest,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
onChanged: (value) {
ref.read(storeProvider.notifier).setSearchQuery(value);
},
);
},
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Row(
children: [
_CategoryChip(
label: context.l10n.storeFilterAll,
icon: Icons.apps,
isSelected: selectedCategory == null,
onTap: () =>
ref.read(storeProvider.notifier).setCategory(null),
),
const SizedBox(width: 8),
_CategoryChip(
label: context.l10n.storeFilterMetadata,
icon: Icons.label_outline,
isSelected: selectedCategory == StoreCategory.metadata,
onTap: () => ref
.read(storeProvider.notifier)
.setCategory(StoreCategory.metadata),
),
const SizedBox(width: 8),
_CategoryChip(
label: context.l10n.storeFilterDownload,
icon: Icons.download_outlined,
isSelected: selectedCategory == StoreCategory.download,
onTap: () => ref
.read(storeProvider.notifier)
.setCategory(StoreCategory.download),
),
const SizedBox(width: 8),
_CategoryChip(
label: context.l10n.storeFilterUtility,
icon: Icons.build_outlined,
isSelected: selectedCategory == StoreCategory.utility,
onTap: () => ref
.read(storeProvider.notifier)
.setCategory(StoreCategory.utility),
),
const SizedBox(width: 8),
_CategoryChip(
label: context.l10n.storeFilterLyrics,
icon: Icons.lyrics_outlined,
isSelected: selectedCategory == StoreCategory.lyrics,
onTap: () => ref
.read(storeProvider.notifier)
.setCategory(StoreCategory.lyrics),
),
const SizedBox(width: 8),
_CategoryChip(
label: context.l10n.storeFilterIntegration,
icon: Icons.link,
isSelected: selectedCategory == StoreCategory.integration,
onTap: () => ref
.read(storeProvider.notifier)
.setCategory(StoreCategory.integration),
),
],
),
),
),
if (isLoading && extensions.isEmpty)
const SliverFillRemaining(
child: Center(child: CircularProgressIndicator()),
)
else if (error != null && extensions.isEmpty)
SliverFillRemaining(child: _buildErrorState(error, colorScheme))
else if (filteredExtensions.isEmpty)
SliverFillRemaining(
child: _buildEmptyState(
hasFilters:
searchQuery.isNotEmpty || selectedCategory != null,
colorScheme: colorScheme,
),
)
else ...[
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text(
'${filteredExtensions.length} ${filteredExtensions.length == 1 ? 'extension' : 'extensions'}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: filteredExtensions.asMap().entries.map((entry) {
final index = entry.key;
@@ -269,9 +284,9 @@ class _StoreTabState extends ConsumerState<StoreTab> {
}).toList(),
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 16)),
const SliverToBoxAdapter(child: SizedBox(height: 16)),
],
],
],
),
@@ -279,6 +294,166 @@ class _StoreTabState extends ConsumerState<StoreTab> {
);
}
Widget _buildSetupRepoState(ColorScheme colorScheme, String? error) {
return Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.store_outlined,
size: 72,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(height: 24),
Text(
context.l10n.storeAddRepoTitle,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
TextField(
controller: _repoUrlController,
decoration: InputDecoration(
hintText: context.l10n.storeRepoUrlHint,
labelText: context.l10n.storeRepoUrlLabel,
prefixIcon: const Icon(Icons.link),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: colorScheme.outline),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: colorScheme.primary, width: 2),
),
),
keyboardType: TextInputType.url,
autocorrect: false,
onSubmitted: (_) => _submitRepoUrl(),
),
if (error != null) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(Icons.error_outline, size: 20, color: colorScheme.onErrorContainer),
const SizedBox(width: 8),
Expanded(
child: Text(
error,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onErrorContainer,
),
),
),
],
),
),
],
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _submitRepoUrl,
icon: const Icon(Icons.add),
label: Text(context.l10n.storeAddRepoButton),
),
),
],
),
),
);
}
void _submitRepoUrl() {
final url = _repoUrlController.text.trim();
if (url.isEmpty) return;
ref.read(storeProvider.notifier).setRegistryUrl(url);
}
void _showChangeRepoDialog(String currentUrl) {
final changeUrlController = TextEditingController(text: currentUrl);
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.storeRepoDialogTitle),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.storeRepoDialogCurrent,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
currentUrl,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
fontSize: 11,
),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 16),
TextField(
controller: changeUrlController,
decoration: InputDecoration(
hintText: context.l10n.storeRepoUrlHint,
labelText: context.l10n.storeNewRepoUrlLabel,
prefixIcon: const Icon(Icons.link),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
keyboardType: TextInputType.url,
autocorrect: false,
),
],
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
ref.read(storeProvider.notifier).removeRegistryUrl();
},
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
),
child: Text(context.l10n.dialogRemove),
),
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () {
final newUrl = changeUrlController.text.trim();
Navigator.of(context).pop();
if (newUrl.isNotEmpty) {
ref.read(storeProvider.notifier).setRegistryUrl(newUrl);
}
},
child: Text(context.l10n.dialogSave),
),
],
),
).then((_) => changeUrlController.dispose());
}
Widget _buildErrorState(String error, ColorScheme colorScheme) {
return Center(
child: Padding(
@@ -289,7 +464,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
Icon(Icons.error_outline, size: 64, color: colorScheme.error),
const SizedBox(height: 16),
Text(
'Failed to load store',
context.l10n.storeLoadError,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
@@ -328,7 +503,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
),
const SizedBox(height: 16),
Text(
hasFilters ? 'No extensions found' : 'No extensions available',
hasFilters ? context.l10n.storeEmptyNoResults : context.l10n.storeEmptyNoExtensions,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
File diff suppressed because it is too large Load Diff
+273 -6
View File
@@ -1209,7 +1209,8 @@ class FFmpegService {
}
/// Unified audio format conversion with full metadata + cover preservation.
/// Supports: FLAC/MP3/Opus -> MP3/Opus (any direction except same format).
/// Supports: FLAC/M4A/MP3/Opus -> MP3/Opus/ALAC/FLAC.
/// ALAC and FLAC targets are lossless (bitrate parameter is ignored).
/// Returns the new file path on success, null on failure.
static Future<String?> convertAudioFormat({
required String inputPath,
@@ -1220,11 +1221,30 @@ class FFmpegService {
bool deleteOriginal = true,
}) async {
final format = targetFormat.toLowerCase();
if (format != 'mp3' && format != 'opus') {
if (!const {'mp3', 'opus', 'alac', 'flac'}.contains(format)) {
_log.e('Unsupported target format: $targetFormat');
return null;
}
// Lossless targets: dedicated single-pass methods
if (format == 'alac') {
return _convertToAlac(
inputPath: inputPath,
metadata: metadata,
coverPath: coverPath,
deleteOriginal: deleteOriginal,
);
}
if (format == 'flac') {
return _convertToFlac(
inputPath: inputPath,
metadata: metadata,
coverPath: coverPath,
deleteOriginal: deleteOriginal,
);
}
// Lossy targets: MP3 / Opus
final extension = format == 'opus' ? '.opus' : '.mp3';
final outputPath = _buildOutputPath(inputPath, extension);
@@ -1296,6 +1316,257 @@ class FFmpegService {
return outputPath;
}
/// Convert any audio format to ALAC (Apple Lossless) in an M4A container.
/// Metadata and cover art are embedded in a single FFmpeg pass.
static Future<String?> _convertToAlac({
required String inputPath,
required Map<String, String> metadata,
String? coverPath,
bool deleteOriginal = true,
}) async {
final outputPath = _buildOutputPath(inputPath, '.m4a');
final cmdBuffer = StringBuffer();
cmdBuffer.write('-i "$inputPath" ');
// Cover art as second input for M4A attached picture
final hasCover = coverPath != null &&
coverPath.trim().isNotEmpty &&
await File(coverPath).exists();
if (hasCover) {
cmdBuffer.write('-i "$coverPath" ');
}
cmdBuffer.write('-map 0:a ');
if (hasCover) {
cmdBuffer.write('-map 1:v -c:v copy -disposition:v:0 attached_pic ');
}
cmdBuffer.write('-c:a alac ');
cmdBuffer.write('-map_metadata -1 ');
// Embed M4A metadata tags
final m4aTags = _convertToM4aTags(metadata);
for (final entry in m4aTags.entries) {
final sanitized = entry.value.replaceAll('"', '\\"');
cmdBuffer.write('-metadata ${entry.key}="$sanitized" ');
}
cmdBuffer.write('"$outputPath" -y');
_log.i(
'Converting ${inputPath.split(Platform.pathSeparator).last} to ALAC',
);
final result = await _execute(cmdBuffer.toString());
if (!result.success) {
_log.e('ALAC conversion failed: ${result.output}');
return null;
}
if (deleteOriginal) {
try {
await File(inputPath).delete();
_log.i(
'Deleted original: ${inputPath.split(Platform.pathSeparator).last}',
);
} catch (e) {
_log.w('Failed to delete original: $e');
}
}
return outputPath;
}
/// Convert any audio format to FLAC with metadata and cover art preservation.
static Future<String?> _convertToFlac({
required String inputPath,
required Map<String, String> metadata,
String? coverPath,
bool deleteOriginal = true,
}) async {
final outputPath = _buildOutputPath(inputPath, '.flac');
final cmdBuffer = StringBuffer();
cmdBuffer.write('-i "$inputPath" ');
final hasCover = coverPath != null &&
coverPath.trim().isNotEmpty &&
await File(coverPath).exists();
if (hasCover) {
cmdBuffer.write('-i "$coverPath" ');
}
cmdBuffer.write('-map 0:a ');
if (hasCover) {
cmdBuffer.write('-map 1:v -c:v copy -disposition:v:0 attached_pic ');
cmdBuffer.write('-metadata:s:v title="Album cover" ');
cmdBuffer.write('-metadata:s:v comment="Cover (front)" ');
}
cmdBuffer.write('-c:a flac -compression_level 8 ');
cmdBuffer.write('-map_metadata 0 ');
final vorbisComments = _normalizeToVorbisComments(metadata);
for (final entry in vorbisComments.entries) {
final sanitized = entry.value.replaceAll('"', '\\"');
cmdBuffer.write('-metadata ${entry.key}="$sanitized" ');
}
cmdBuffer.write('"$outputPath" -y');
_log.i(
'Converting ${inputPath.split(Platform.pathSeparator).last} to FLAC',
);
final result = await _execute(cmdBuffer.toString());
if (!result.success) {
_log.e('FLAC conversion failed: ${result.output}');
return null;
}
if (deleteOriginal) {
try {
await File(inputPath).delete();
_log.i(
'Deleted original: ${inputPath.split(Platform.pathSeparator).last}',
);
} catch (e) {
_log.w('Failed to delete original: $e');
}
}
return outputPath;
}
/// Normalize metadata keys to standard Vorbis comment names, filtering out
/// technical fields (bit_depth, sample_rate, duration, etc.).
static Map<String, String> _normalizeToVorbisComments(
Map<String, String> metadata,
) {
final vorbis = <String, String>{};
for (final entry in metadata.entries) {
final key = entry.key.toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]'), '');
final value = entry.value;
if (value.trim().isEmpty) continue;
switch (key) {
case 'TITLE':
vorbis['TITLE'] = value;
break;
case 'ARTIST':
vorbis['ARTIST'] = value;
break;
case 'ALBUM':
vorbis['ALBUM'] = value;
break;
case 'ALBUMARTIST':
vorbis['ALBUMARTIST'] = value;
break;
case 'TRACKNUMBER':
case 'TRACKNBR':
case 'TRACK':
case 'TRCK':
if (value != '0') vorbis['TRACKNUMBER'] = value;
break;
case 'DISCNUMBER':
case 'DISC':
case 'TPOS':
if (value != '0') vorbis['DISCNUMBER'] = value;
break;
case 'DATE':
case 'YEAR':
vorbis['DATE'] = value;
break;
case 'GENRE':
vorbis['GENRE'] = value;
break;
case 'ISRC':
vorbis['ISRC'] = value;
break;
case 'LABEL':
case 'ORGANIZATION':
vorbis['ORGANIZATION'] = value;
break;
case 'COPYRIGHT':
vorbis['COPYRIGHT'] = value;
break;
case 'COMPOSER':
vorbis['COMPOSER'] = value;
break;
case 'COMMENT':
vorbis['COMMENT'] = value;
break;
case 'LYRICS':
case 'UNSYNCEDLYRICS':
vorbis['LYRICS'] = value;
vorbis['UNSYNCEDLYRICS'] = value;
break;
}
}
return vorbis;
}
/// Map Vorbis comment keys to M4A/MP4 metadata tag names for FFmpeg.
static Map<String, String> _convertToM4aTags(
Map<String, String> metadata,
) {
final m4aMap = <String, String>{};
for (final entry in metadata.entries) {
final key = entry.key.toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]'), '');
final value = entry.value;
if (value.trim().isEmpty) continue;
switch (key) {
case 'TITLE':
m4aMap['title'] = value;
break;
case 'ARTIST':
m4aMap['artist'] = value;
break;
case 'ALBUM':
m4aMap['album'] = value;
break;
case 'ALBUMARTIST':
m4aMap['album_artist'] = value;
break;
case 'TRACKNUMBER':
case 'TRACK':
case 'TRCK':
m4aMap['track'] = value;
break;
case 'DISCNUMBER':
case 'DISC':
case 'TPOS':
m4aMap['disc'] = value;
break;
case 'DATE':
case 'YEAR':
m4aMap['date'] = value;
break;
case 'GENRE':
m4aMap['genre'] = value;
break;
case 'COMPOSER':
m4aMap['composer'] = value;
break;
case 'COMMENT':
m4aMap['comment'] = value;
break;
case 'COPYRIGHT':
m4aMap['copyright'] = value;
break;
case 'LYRICS':
case 'UNSYNCEDLYRICS':
m4aMap['lyrics'] = value;
break;
}
}
return m4aMap;
}
static Map<String, String> _convertToId3Tags(
Map<String, String> vorbisMetadata,
) {
@@ -1385,7 +1656,6 @@ class FFmpegService {
final track = tracks[i];
onProgress?.call(i + 1, tracks.length);
// Sanitize filename
final sanitizedTitle = track.title
.replaceAll(RegExp(r'[<>:"/\\|?*]'), '_')
.replaceAll(RegExp(r'\s+'), ' ')
@@ -1394,11 +1664,9 @@ class FFmpegService {
final outputFileName = '$trackNumStr - $sanitizedTitle.$outputExt';
final outputPath = '$outputDir${Platform.pathSeparator}$outputFileName';
// Build FFmpeg command for this track
final StringBuffer cmdBuffer = StringBuffer();
cmdBuffer.write('-i "$audioPath" ');
// Time range
final startTime = _formatSecondsForFFmpeg(track.startSec);
cmdBuffer.write('-ss $startTime ');
@@ -1413,7 +1681,6 @@ class FFmpegService {
cmdBuffer.write('-c:a copy ');
}
// Metadata
final artist = track.artist.isNotEmpty ? track.artist : (albumMetadata['artist'] ?? '');
final album = albumMetadata['album'] ?? '';
final genre = albumMetadata['genre'] ?? '';
+30 -3
View File
@@ -1,3 +1,5 @@
import 'dart:io';
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
@@ -176,7 +178,6 @@ class LibraryDatabase {
_log.i('Upgrading library database from v$oldVersion to v$newVersion');
if (oldVersion < 2) {
// Add cover_path column
await db.execute('ALTER TABLE library ADD COLUMN cover_path TEXT');
_log.i('Added cover_path column');
}
@@ -242,8 +243,6 @@ class LibraryDatabase {
};
}
// CRUD Operations
Future<void> upsert(Map<String, dynamic> json) async {
final db = await database;
await db.insert(
@@ -473,6 +472,34 @@ class LibraryDatabase {
return result;
}
/// Export file modification times to a compact line-based snapshot that
/// native code can read without receiving a large method-channel payload.
Future<String> writeFileModTimesSnapshot() async {
final db = await database;
final rows = await db.rawQuery(
'SELECT file_path, COALESCE(file_mod_time, 0) AS file_mod_time FROM library',
);
final tempDir = await getTemporaryDirectory();
final file = File(
join(
tempDir.path,
'library_file_mod_times_${DateTime.now().microsecondsSinceEpoch}.tsv',
),
);
final buffer = StringBuffer();
for (final row in rows) {
final path = row['file_path'] as String?;
if (path == null || path.isEmpty) continue;
final modTime = (row['file_mod_time'] as num?)?.toInt() ?? 0;
buffer
..write(modTime)
..write('\t')
..writeln(path);
}
await file.writeAsString(buffer.toString(), flush: true);
return file.path;
}
/// Update file_mod_time for existing rows using file_path as key.
Future<void> updateFileModTimes(Map<String, int> fileModTimes) async {
if (fileModTimes.isEmpty) return;
@@ -0,0 +1,338 @@
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
class LocalTrackRedownloadResolution {
final LocalLibraryItem localItem;
final Track? match;
final int score;
final String reason;
const LocalTrackRedownloadResolution({
required this.localItem,
required this.match,
required this.score,
required this.reason,
});
bool get canQueue => match != null;
}
class LocalTrackRedownloadService {
static const int _minimumConfidenceScore = 85;
static const int _ambiguousScoreGap = 8;
static Future<LocalTrackRedownloadResolution> resolveBestMatch(
LocalLibraryItem item, {
required bool includeExtensions,
}) async {
final query = _buildSearchQuery(item);
final rawResults = await PlatformBridge.searchTracksWithMetadataProviders(
query,
limit: 10,
includeExtensions: includeExtensions,
);
if (rawResults.isEmpty) {
return LocalTrackRedownloadResolution(
localItem: item,
match: null,
score: 0,
reason: 'No candidates found',
);
}
final scored =
rawResults
.map(
(raw) => (
track: _parseSearchTrack(raw),
score: _scoreMatch(item, raw),
),
)
.where((entry) => entry.track.name.trim().isNotEmpty)
.toList(growable: false)
..sort((a, b) => b.score.compareTo(a.score));
if (scored.isEmpty) {
return LocalTrackRedownloadResolution(
localItem: item,
match: null,
score: 0,
reason: 'No usable candidates found',
);
}
final best = scored.first;
final runnerUp = scored.length > 1 ? scored[1] : null;
final exactIsrc =
_normalizedIsrc(item.isrc) != null &&
_normalizedIsrc(item.isrc) == _normalizedIsrc(best.track.isrc);
final isAmbiguous =
!exactIsrc &&
runnerUp != null &&
best.score < (_minimumConfidenceScore + 10) &&
(best.score - runnerUp.score) <= _ambiguousScoreGap;
if (!exactIsrc && (best.score < _minimumConfidenceScore || isAmbiguous)) {
return LocalTrackRedownloadResolution(
localItem: item,
match: null,
score: best.score,
reason: isAmbiguous ? 'Ambiguous match' : 'Low-confidence match',
);
}
return LocalTrackRedownloadResolution(
localItem: item,
match: best.track,
score: best.score,
reason: exactIsrc ? 'Exact ISRC match' : 'High-confidence metadata match',
);
}
static String preferredFlacService(AppSettings settings) {
switch (settings.defaultService.toLowerCase()) {
case 'tidal':
case 'qobuz':
case 'deezer':
return settings.defaultService.toLowerCase();
default:
return 'tidal';
}
}
static String preferredFlacQualityForService(String service) {
return service.toLowerCase() == 'deezer' ? 'FLAC' : 'LOSSLESS';
}
static String _buildSearchQuery(LocalLibraryItem item) {
final artist = _primaryArtist(item.artistName);
final album = item.albumName.trim();
if (album.isNotEmpty && album.toLowerCase() != 'unknown album') {
return '${item.trackName} $artist $album'.trim();
}
return '${item.trackName} $artist'.trim();
}
static Track _parseSearchTrack(Map<String, dynamic> data) {
final durationMs = _extractDurationMs(data);
final itemType = data['item_type']?.toString();
return Track(
id: (data['spotify_id'] ?? data['id'] ?? '').toString(),
name: (data['name'] ?? '').toString(),
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
albumName: (data['album_name'] ?? data['album'] ?? '').toString(),
albumArtist: data['album_artist']?.toString(),
artistId: (data['artist_id'] ?? data['artistId'])?.toString(),
albumId: data['album_id']?.toString(),
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
isrc: data['isrc']?.toString(),
duration: (durationMs / 1000).round(),
trackNumber: data['track_number'] as int?,
discNumber: data['disc_number'] as int?,
releaseDate: data['release_date']?.toString(),
totalTracks: data['total_tracks'] as int?,
source: data['source']?.toString() ?? data['provider_id']?.toString(),
albumType: data['album_type']?.toString(),
itemType: itemType,
);
}
static int _extractDurationMs(Map<String, dynamic> data) {
final durationMsRaw = data['duration_ms'];
if (durationMsRaw is num && durationMsRaw > 0) {
return durationMsRaw.toInt();
}
if (durationMsRaw is String) {
final parsed = num.tryParse(durationMsRaw.trim());
if (parsed != null && parsed > 0) {
return parsed.toInt();
}
}
final durationSecRaw = data['duration'];
if (durationSecRaw is num && durationSecRaw > 0) {
return (durationSecRaw * 1000).toInt();
}
if (durationSecRaw is String) {
final parsed = num.tryParse(durationSecRaw.trim());
if (parsed != null && parsed > 0) {
return (parsed * 1000).toInt();
}
}
return 0;
}
static int _scoreMatch(LocalLibraryItem item, Map<String, dynamic> raw) {
final track = _parseSearchTrack(raw);
var score = 0;
final localIsrc = _normalizedIsrc(item.isrc);
final candidateIsrc = _normalizedIsrc(track.isrc);
if (localIsrc != null && candidateIsrc != null) {
score += localIsrc == candidateIsrc ? 140 : -120;
}
final localTitle = _normalizedTitle(item.trackName);
final candidateTitle = _normalizedTitle(track.name);
if (localTitle == candidateTitle) {
score += 45;
} else if (_tokenOverlap(localTitle, candidateTitle) >= 0.75) {
score += 24;
} else {
score -= 25;
}
final localArtist = _normalizedArtistGroup(item.artistName);
final candidateArtist = _normalizedArtistGroup(track.artistName);
final artistOverlap = _tokenOverlap(localArtist, candidateArtist);
if (localArtist == candidateArtist) {
score += 30;
} else if (artistOverlap >= 0.6) {
score += 16;
} else {
score -= 20;
}
final localAlbum = _normalizedText(item.albumName);
final candidateAlbum = _normalizedText(track.albumName);
if (localAlbum.isNotEmpty && candidateAlbum.isNotEmpty) {
if (localAlbum == candidateAlbum) {
score += 12;
} else if (_tokenOverlap(localAlbum, candidateAlbum) >= 0.7) {
score += 6;
}
}
final localDuration = item.duration ?? 0;
final candidateDuration = track.duration;
if (localDuration > 0 && candidateDuration > 0) {
final diff = (localDuration - candidateDuration).abs();
if (diff <= 2) {
score += 20;
} else if (diff <= 5) {
score += 12;
} else if (diff <= 10) {
score += 5;
} else if (diff > 20) {
score -= 30;
}
}
if (item.trackNumber != null &&
track.trackNumber != null &&
item.trackNumber == track.trackNumber) {
score += 6;
}
if (item.discNumber != null &&
track.discNumber != null &&
item.discNumber == track.discNumber) {
score += 4;
}
final localYear = _extractYear(item.releaseDate);
final candidateYear = _extractYear(track.releaseDate);
if (localYear != null &&
candidateYear != null &&
localYear == candidateYear) {
score += 4;
}
score += _versionPenalty(item.trackName, track.name);
return score;
}
static String? _normalizedIsrc(String? value) {
final normalized = value?.trim().toUpperCase();
if (normalized == null || normalized.isEmpty) {
return null;
}
return normalized;
}
static String _normalizedTitle(String value) {
final cleaned = _normalizedText(value)
.replaceAll(RegExp(r'\b(feat|ft|featuring)\b.*$'), ' ')
.replaceAll(RegExp(r'\b(remaster(?:ed)?|deluxe|bonus)\b'), ' ')
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
return cleaned;
}
static String _normalizedArtistGroup(String value) {
return _normalizedText(
value
.replaceAll(RegExp(r'\b(feat|ft|featuring|with|x)\b'), ',')
.replaceAll('&', ','),
);
}
static String _primaryArtist(String value) {
final parts = _normalizedArtistGroup(
value,
).split(',').map((part) => part.trim()).where((part) => part.isNotEmpty);
return parts.isEmpty ? value.trim() : parts.first;
}
static String _normalizedText(String value) {
return value
.toLowerCase()
.replaceAll(RegExp(r'[\(\)\[\]\{\}]'), ' ')
.replaceAll(RegExp(r'[^a-z0-9, ]+'), ' ')
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
}
static double _tokenOverlap(String left, String right) {
final leftTokens = left
.split(RegExp(r'[\s,]+'))
.where((token) => token.isNotEmpty)
.toSet();
final rightTokens = right
.split(RegExp(r'[\s,]+'))
.where((token) => token.isNotEmpty)
.toSet();
if (leftTokens.isEmpty || rightTokens.isEmpty) {
return 0;
}
final intersection = leftTokens.intersection(rightTokens).length;
final denominator = leftTokens.length > rightTokens.length
? leftTokens.length
: rightTokens.length;
return intersection / denominator;
}
static int _versionPenalty(String localTitle, String candidateTitle) {
const riskyMarkers = [
'live',
'karaoke',
'instrumental',
'acoustic',
'radio edit',
'sped up',
'slowed',
];
final local = _normalizedText(localTitle);
final candidate = _normalizedText(candidateTitle);
var penalty = 0;
for (final marker in riskyMarkers) {
final localHas = local.contains(marker);
final candidateHas = candidate.contains(marker);
if (!localHas && candidateHas) {
penalty -= 18;
}
}
return penalty;
}
static int? _extractYear(String? date) {
if (date == null || date.length < 4) {
return null;
}
return int.tryParse(date.substring(0, 4));
}
}
+93
View File
@@ -535,11 +535,48 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> getQobuzMetadata(
String resourceType,
String resourceId,
) async {
final result = await _channel.invokeMethod('getQobuzMetadata', {
'resource_type': resourceType,
'resource_id': resourceId,
});
if (result == null) {
throw Exception(
'getQobuzMetadata returned null for $resourceType:$resourceId',
);
}
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> parseQobuzUrl(String url) async {
final result = await _channel.invokeMethod('parseQobuzUrl', {'url': url});
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> parseTidalUrl(String url) async {
final result = await _channel.invokeMethod('parseTidalUrl', {'url': url});
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> getTidalMetadata(
String resourceType,
String resourceId,
) async {
final result = await _channel.invokeMethod('getTidalMetadata', {
'resource_type': resourceType,
'resource_id': resourceId,
});
if (result == null) {
throw Exception(
'getTidalMetadata returned null for $resourceType:$resourceId',
);
}
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> convertTidalToSpotifyDeezer(
String tidalUrl,
) async {
@@ -779,6 +816,22 @@ class PlatformBridge {
return list.map((e) => e as Map<String, dynamic>).toList();
}
static Future<List<Map<String, dynamic>>> searchTracksWithMetadataProviders(
String query, {
int limit = 20,
bool includeExtensions = true,
}) async {
_log.d(
'searchTracksWithMetadataProviders: "$query", includeExtensions=$includeExtensions',
);
final result = await _channel.invokeMethod(
'searchTracksWithMetadataProviders',
{'query': query, 'limit': limit, 'include_extensions': includeExtensions},
);
final list = jsonDecode(result as String) as List<dynamic>;
return list.map((e) => e as Map<String, dynamic>).toList();
}
static Future<void> cleanupExtensions() async {
_log.d('cleanupExtensions');
await _channel.invokeMethod('cleanupExtensions');
@@ -1037,6 +1090,17 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> scanLibraryFolderIncrementalFromSnapshot(
String folderPath,
String snapshotPath,
) async {
final result = await _channel.invokeMethod(
'scanLibraryFolderIncrementalFromSnapshot',
{'folder_path': folderPath, 'snapshot_path': snapshotPath},
);
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<List<Map<String, dynamic>>> scanSafTree(String treeUri) async {
_log.i('scanSafTree: $treeUri');
final result = await _channel.invokeMethod('scanSafTree', {
@@ -1062,6 +1126,17 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> scanSafTreeIncrementalFromSnapshot(
String treeUri,
String snapshotPath,
) async {
final result = await _channel.invokeMethod(
'scanSafTreeIncrementalFromSnapshot',
{'tree_uri': treeUri, 'snapshot_path': snapshotPath},
);
return jsonDecode(result as String) as Map<String, dynamic>;
}
/// Get last-modified timestamps for a list of SAF file URIs.
/// Returns map uri -> modTime (unix millis), only for files that still exist.
static Future<Map<String, int>> getSafFileModTimes(List<String> uris) async {
@@ -1191,6 +1266,24 @@ class PlatformBridge {
await _channel.invokeMethod('initExtensionStore', {'cache_dir': cacheDir});
}
static Future<void> setStoreRegistryUrl(String registryUrl) async {
_log.d('setStoreRegistryUrl: $registryUrl');
await _channel.invokeMethod('setStoreRegistryUrl', {
'registry_url': registryUrl,
});
}
static Future<String> getStoreRegistryUrl() async {
_log.d('getStoreRegistryUrl');
final result = await _channel.invokeMethod('getStoreRegistryUrl');
return result as String? ?? '';
}
static Future<void> clearStoreRegistryUrl() async {
_log.d('clearStoreRegistryUrl');
await _channel.invokeMethod('clearStoreRegistryUrl');
}
static Future<List<Map<String, dynamic>>> getStoreExtensions({
bool forceRefresh = false,
}) async {
+41 -9
View File
@@ -20,6 +20,22 @@ final _iosLegacyRelativeDocumentsPattern = RegExp(
r'^Data/Application/[A-F0-9\-]+/Documents(?:/(.*))?$',
caseSensitive: false,
);
final _iosNestedLegacyDocumentsPattern = RegExp(
r'/Documents/Data/Application/[A-F0-9\-]+/Documents(?:/(.*))?$',
caseSensitive: false,
);
String _normalizeRecoveredIosSuffix(String suffix) {
final trimmed = suffix.trim();
if (trimmed.isEmpty) return '';
return trimmed.startsWith('/') ? trimmed.substring(1) : trimmed;
}
String _joinRecoveredIosPath(String documentsPath, String suffix) {
final normalizedSuffix = _normalizeRecoveredIosSuffix(suffix);
if (normalizedSuffix.isEmpty) return documentsPath;
return '$documentsPath/$normalizedSuffix';
}
/// Checks if a path is a valid writable directory on iOS.
/// Returns false if:
@@ -43,6 +59,12 @@ bool isValidIosWritablePath(String path) {
return false;
}
// Reject stale paths where an old sandbox container path has been embedded
// inside the current Documents directory.
if (_iosNestedLegacyDocumentsPattern.hasMatch(path)) {
return false;
}
// Ensure path contains a valid subdirectory (Documents, tmp, Library, etc.)
// This handles cases where FilePicker returns container root
final containerPattern = RegExp(
@@ -70,11 +92,19 @@ Future<String> validateOrFixIosPath(
if (!Platform.isIOS) return path;
final trimmed = path.trim();
final docDir = await getApplicationDocumentsDirectory();
final nestedLegacyMatch = _iosNestedLegacyDocumentsPattern.firstMatch(
trimmed,
);
if (nestedLegacyMatch != null) {
return _joinRecoveredIosPath(docDir.path, nestedLegacyMatch.group(1) ?? '');
}
if (isValidIosWritablePath(trimmed)) {
return trimmed;
}
final docDir = await getApplicationDocumentsDirectory();
final candidates = <String>[];
if (trimmed.isNotEmpty) {
@@ -92,14 +122,8 @@ Future<String> validateOrFixIosPath(
trimmed,
);
if (legacyRelativeMatch != null) {
final suffix = (legacyRelativeMatch.group(1) ?? '').trim();
final normalizedSuffix = suffix.startsWith('/')
? suffix.substring(1)
: suffix;
candidates.add(
normalizedSuffix.isEmpty
? docDir.path
: '${docDir.path}/$normalizedSuffix',
_joinRecoveredIosPath(docDir.path, legacyRelativeMatch.group(1) ?? ''),
);
}
@@ -109,7 +133,7 @@ Future<String> validateOrFixIosPath(
final index = trimmed.indexOf(documentsMarker);
if (index >= 0) {
final suffix = trimmed.substring(index + documentsMarker.length).trim();
candidates.add(suffix.isEmpty ? docDir.path : '${docDir.path}/$suffix');
candidates.add(_joinRecoveredIosPath(docDir.path, suffix));
}
}
@@ -181,6 +205,14 @@ IosPathValidationResult validateIosPath(String path) {
);
}
if (_iosNestedLegacyDocumentsPattern.hasMatch(path)) {
return const IosPathValidationResult(
isValid: false,
errorReason:
'Invalid iOS app folder path. Please choose App Documents or another local folder.',
);
}
// Check for container root without subdirectory
final containerPattern = RegExp(
r'/var/mobile/Containers/Data/Application/[A-F0-9\-]+',
+39
View File
@@ -8,6 +8,33 @@ const _androidStoragePathAliases = <String>[
'/mnt/sdcard',
];
/// Audio file extensions that the app commonly produces or converts between.
/// Used to generate extension-stripped match keys so that a file converted from
/// one format to another (e.g. .flac .opus) is still recognised as the same
/// track.
const _audioExtensions = <String>[
'.flac',
'.m4a',
'.mp3',
'.opus',
'.ogg',
'.wav',
'.aac',
];
/// Strips a trailing audio extension from [path] if present.
/// Returns the path without extension, or `null` if no known audio extension
/// was found.
String? _stripAudioExtension(String path) {
final lower = path.toLowerCase();
for (final ext in _audioExtensions) {
if (lower.endsWith(ext)) {
return path.substring(0, path.length - ext.length);
}
}
return null;
}
Set<String> buildPathMatchKeys(String? filePath) {
final raw = filePath?.trim() ?? '';
if (raw.isEmpty) return const {};
@@ -79,6 +106,18 @@ Set<String> buildPathMatchKeys(String? filePath) {
}
addNormalized(cleaned);
// Add extension-stripped variants so that a file converted from one audio
// format to another (e.g. Song.flac Song.opus) still matches.
final extensionStrippedKeys = <String>{};
for (final key in keys) {
final stripped = _stripAudioExtension(key);
if (stripped != null && stripped.isNotEmpty) {
extensionStrippedKeys.add(stripped);
}
}
keys.addAll(extensionStrippedKeys);
return keys;
}
+5 -5
View File
@@ -23,7 +23,7 @@ class BuiltInService {
}
/// Default quality options for built-in services
/// Note: Tidal lossy (HIGH) removed - use YouTube for lossy downloads
/// Default quality options for each built-in service
const _builtInServices = [
BuiltInService(
id: 'tidal',
@@ -83,9 +83,9 @@ const _builtInServices = [
label: 'YouTube',
qualityOptions: [
QualityOption(
id: 'opus_256',
label: 'Opus 256kbps',
description: 'Best quality lossy (~8MB per track)',
id: 'opus_320',
label: 'Opus 320kbps',
description: 'Best quality lossy (~10MB per track)',
),
QualityOption(
id: 'mp3_320',
@@ -146,7 +146,7 @@ class DownloadServicePicker extends ConsumerStatefulWidget {
}
class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
static const List<int> _youtubeOpusSupportedBitrates = [128, 256];
static const List<int> _youtubeOpusSupportedBitrates = [128, 256, 320];
static const List<int> _youtubeMp3SupportedBitrates = [128, 256, 320];
late String _selectedService;
+7 -5
View File
@@ -20,6 +20,7 @@ Future<void> showAddTracksToPlaylistSheet(
BuildContext context,
WidgetRef ref,
List<Track> tracks,
{String? playlistNamePrefill}
) async {
if (tracks.isEmpty) return;
@@ -31,15 +32,16 @@ Future<void> showAddTracksToPlaylistSheet(
showDragHandle: true,
isScrollControlled: true,
builder: (sheetContext) {
return _PlaylistPickerSheetContent(tracks: tracks);
return _PlaylistPickerSheetContent(tracks: tracks, playlistNamePrefill: playlistNamePrefill);
},
);
}
class _PlaylistPickerSheetContent extends ConsumerStatefulWidget {
final List<Track> tracks;
final String? playlistNamePrefill;
const _PlaylistPickerSheetContent({required this.tracks});
const _PlaylistPickerSheetContent({required this.tracks, this.playlistNamePrefill});
@override
ConsumerState<_PlaylistPickerSheetContent> createState() =>
@@ -130,7 +132,7 @@ class _PlaylistPickerSheetContentState
leading: const Icon(Icons.add_circle_outline),
title: Text(context.l10n.collectionCreatePlaylist),
onTap: () async {
final name = await _promptPlaylistName(context);
final name = await _promptPlaylistName(context, widget.playlistNamePrefill);
if (name == null || name.trim().isEmpty || !context.mounted) {
return;
}
@@ -221,8 +223,8 @@ class _PlaylistPickerSheetContentState
}
}
Future<String?> _promptPlaylistName(BuildContext context) async {
final controller = TextEditingController();
Future<String?> _promptPlaylistName(BuildContext context, String? playlistNamePrefill) async {
final controller = TextEditingController(text: playlistNamePrefill);
final formKey = GlobalKey<FormState>();
final result = await showDialog<String>(
+1 -1
View File
@@ -157,7 +157,7 @@ class _UpdateDialogState extends State<UpdateDialog> {
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_VersionChip(version: AppInfo.version, label: context.l10n.updateCurrent, colorScheme: colorScheme),
_VersionChip(version: AppInfo.displayVersion, label: context.l10n.updateCurrent, colorScheme: colorScheme),
const SizedBox(width: 12),
Icon(Icons.arrow_forward_rounded, size: 20, color: colorScheme.primary),
const SizedBox(width: 12),
+12 -20
View File
@@ -133,10 +133,10 @@ packages:
dependency: transitive
description:
name: characters
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
version: "1.4.0"
version: "1.4.1"
checked_yaml:
dependency: transitive
description:
@@ -557,14 +557,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.5"
js:
dependency: transitive
description:
name: js
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
url: "https://pub.dev"
source: hosted
version: "0.7.2"
json_annotation:
dependency: "direct main"
description:
@@ -633,18 +625,18 @@ packages:
dependency: transitive
description:
name: matcher
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev"
source: hosted
version: "0.12.17"
version: "0.12.19"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
version: "0.11.1"
version: "0.13.0"
meta:
dependency: transitive
description:
@@ -1166,26 +1158,26 @@ packages:
dependency: transitive
description:
name: test
sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7"
sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7"
url: "https://pub.dev"
source: hosted
version: "1.26.3"
version: "1.30.0"
test_api:
dependency: transitive
description:
name: test_api
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
url: "https://pub.dev"
source: hosted
version: "0.7.7"
version: "0.7.10"
test_core:
dependency: transitive
description:
name: test_core
sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0"
sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51"
url: "https://pub.dev"
source: hosted
version: "0.6.12"
version: "0.6.16"
timezone:
dependency: transitive
description:
+1 -1
View File
@@ -1,7 +1,7 @@
name: spotiflac_android
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Deezer
publish_to: "none"
version: 3.7.2+105
version: 3.8.6+112
environment:
sdk: ^3.10.0