Compare commits

...

88 Commits

Author SHA1 Message Date
zarzet 46afa6e733 fix: use HTML parse mode for Telegram notifications to handle special chars 2026-01-21 13:30:35 +07:00
zarzet c01b189477 fix: discography download context issue after quality picker closes 2026-01-21 13:04:48 +07:00
zarzet 966935b677 feat: add missing platform bridge functions for batch duplicate check and cross-platform IDs 2026-01-21 12:22:46 +07:00
zarzet f2f8ca4528 feat: artist navigation from album, UI improvements, concise changelog
- Add tappable artist name in album screen to navigate to artist page
- Show track number instead of cover image in album track list
- Add release date badge next to track count on album screen
- Modernize Download All buttons with rounded corners (borderRadius: 24)
- Add downloaded indicator for recent items (primary colored subtitle)
- Condense v3.2.0 changelog and add note about concise format
- Fix withOpacity deprecation and unnecessary null assertion in home_tab
- Go backend: add artist_id support for Spotify, Deezer, and extensions
2026-01-21 12:07:50 +07:00
zarzet 7844bd2f42 docs: add discography download to changelog as highly requested feature 2026-01-21 10:28:17 +07:00
zarzet ac3d51e2cd feat: add discography download with album selection support
- Download entire artist discography, albums only, or singles only
- Album selection mode with multi-select and batch download
- Progress dialog while fetching tracks from albums
- Skip already downloaded tracks (checks history)
- Works with Spotify, Deezer, and Extensions
- Add 18 localization strings for discography feature
2026-01-21 10:26:35 +07:00
zarzet b899b54bb8 perf: migrate history to SQLite and optimize palette extraction
- Add SQLite database for download history with O(1) indexed lookups
- Add in-memory Map indexes for O(1) getBySpotifyId/getByIsrc
- Automatic migration from SharedPreferences on first launch
- Fix PaletteService to use PaletteGenerator (isolate approach didn't work)
- Use small image size (64x64) and limited colors (8) for speed
- Add caching to avoid re-extraction
- All screens now use consistent PaletteService
- Update CHANGELOG with all v3.2.0 changes
2026-01-21 10:05:39 +07:00
zarzet 7a17de49b2 fix: add duration_ms to home feed items and bump version to 3.2.0
- Add duration_ms field to ExploreItem model
- Parse duration_ms from spotify-web and ytmusic home feed responses
- Update _downloadExploreTrack to use item.durationMs
- Fixes track duration showing 0:00 in metadata screen after download
- Bump version to 3.2.0+63
2026-01-21 09:16:11 +07:00
zarzet 79180dd918 feat: add Home Feed with pull-to-refresh and gobackend.getLocalTime() API
- Add Home Feed/Explore feature with extension capabilities system
- Add pull-to-refresh on home feed (replaces refresh button)
- Add gobackend.getLocalTime() API for accurate device timezone detection
- Add YT Music Quick Picks UI with swipeable vertical format
- Fix greeting time showing wrong time due to Goja getTimezoneOffset() returning 0
- Update spotify-web and ytmusic extensions to use getLocalTime()
- Add Turkish language support
- Update CHANGELOG for v3.2.0
2026-01-21 08:30:44 +07:00
zarzet e725a7be77 feat: convert GitHub Markdown to Telegram format in release notification 2026-01-20 10:12:01 +07:00
zarzet d960708dac feat: improve Telegram notification - upload IPA, remove redundant links, increase changelog limit 2026-01-20 10:08:35 +07:00
zarzet c62ad005f5 docs: update README and release workflow 2026-01-20 09:58:31 +07:00
zarzet 68fa1bfdae feat: improve providers, l10n updates, and UI enhancements (testing) 2026-01-20 09:55:46 +07:00
zarzet bd6b23400e Update screenshots, funding links, and VirusTotal hash 2026-01-20 05:57:43 +07:00
zarzet b6d2fea847 chore: bump version to 3.1.3+62 2026-01-20 04:55:02 +07:00
zarzet f356e53f7e feat: auto-enrich genre/label from Deezer for built-in providers
- Add GetExtendedMetadataByISRC function in deezer.go
  - Searches track by ISRC then fetches album extended metadata
- Call enrichment in DownloadWithExtensionFallback before built-in download
  - Only enriches if genre/label are empty and ISRC is available
  - Logs enrichment results for debugging
2026-01-20 04:09:41 +07:00
zarzet bb1ff187a3 fix: include genre, label, copyright in DownloadResponse
Extended metadata was being embedded into FLAC files but not returned
in the response to Flutter, causing history to not store these fields.

Fixed in 3 places in extension_providers.go:
- Source extension download response
- Extension fallback download response
- Built-in provider (Tidal/Qobuz/Amazon) response
2026-01-20 03:59:55 +07:00
zarzet d99a1b1c21 perf: streaming M4A metadata embedding and HTTP client refactor
- Refactor EmbedM4AMetadata to use streaming instead of loading entire file
- Use os.Open + ReadAt instead of os.ReadFile for memory efficiency
- Atomic file replacement via temp file + rename for safer writes
- New helper functions: findAtomInRange, readAtomHeaderAt, copyRange, buildUdtaAtom
- Refactor GetM4AQuality to use streaming with findAudioSampleEntry
- Use NewHTTPClientWithTimeout helper in lyrics.go, qobuz.go, tidal.go
- Update CHANGELOG with performance improvements and MP3 metadata support
2026-01-20 03:46:43 +07:00
zarzet c36497e87c perf: optimize widget rebuilds and reduce allocations
- Cache SharedPreferences instance in DownloadHistoryNotifier and DownloadQueueNotifier
- Precompile regex for folder sanitization and year extraction
- Use indexWhere instead of firstWhere with placeholder object
- Use selective watch for downloadQueueProvider (queuedCount, items)
- Pass Track directly to _buildTrackTile instead of index lookup
- Pass historyItems as parameter to _buildRecentAccess
- Add extended metadata (genre, label, copyright) support for MP3
2026-01-20 03:25:33 +07:00
zarzet 03027813c1 chore: cleanup unused code and dead imports 2026-01-20 02:10:10 +07:00
zarzet 8e9d0c3e9a fix: use path only for JsonCacheInfoRepository
JsonCacheInfoRepository assertion requires either path OR databaseName, not both.
Using path only to ensure database is stored in persistent directory.
2026-01-19 23:26:10 +07:00
zarzet 6c8813c9de fix: ensure CoverCacheManager initializes before app renders
- Move CoverCacheManager.initialize() to run BEFORE other services
- Add debug log to confirm initialization status
- Fixes race condition where widgets render before cache is ready
2026-01-19 23:22:53 +07:00
zarzet ec314eb479 fix: store cache database in persistent directory
- Add path parameter to JsonCacheInfoRepository
- Add fallback to DefaultCacheManager if initialization fails
- Add debug logging for troubleshooting
- Fix issue where cache database was in temp dir while files in persistent
2026-01-19 23:14:33 +07:00
zarzet 77e4457244 feat: add persistent cover image cache
- Add CoverCacheManager service for persistent image caching
- Cache stored in app_flutter/cover_cache/ (not cleared by system)
- Maximum 1000 images cached for up to 365 days
- Update all 11 screens to use persistent cache manager
- Add flutter_cache_manager and path dependencies
- Update CHANGELOG.md with all changes for v3.1.3
2026-01-19 22:55:53 +07:00
zarzet 0119db094d feat: add extended metadata (genre, label, copyright) support
- Add genre, label, copyright fields to ExtTrackMetadata and DownloadResponse
- Add utils.randomUserAgent() for extensions to get random User-Agent strings
- Fix VM race condition panic by adding mutex locks to all provider methods
- Fix Tidal release date fallback when req.ReleaseDate is empty
- Display genre, label, copyright in track metadata screen
- Store extended metadata in download history for persistence
- Add trackGenre, trackLabel, trackCopyright localization strings
2026-01-19 21:13:40 +07:00
zarzet 9c35515d6f docs: add code of conduct and contributing guidelines 2026-01-19 18:58:25 +07:00
zarzet 1546d7da22 feat: add external LRC lyrics file support and fix locale parsing
- Add lyrics mode setting (embed/external/both) for saving lyrics
- Implement SaveLRCFile() in Go backend for all providers (Tidal, Qobuz, Amazon)
- Fix locale parsing in app.dart to handle country codes (e.g., pt_PT -> Locale('pt', 'PT'))
- Change Portuguese label from Portugal to Brasil in language settings
2026-01-19 18:57:27 +07:00
zarzet 61720f3f2a chore(ios): sync FFmpeg service and add palette_generator dependency 2026-01-19 02:55:39 +07:00
zarzet 7749399239 docs: add translator credits to changelog 2026-01-19 02:41:57 +07:00
zarzet d143b82068 fix: add es_ES and pt_PT locale codes to language selector 2026-01-19 02:33:12 +07:00
zarzet 606e7c1079 fix: change translator links from GitHub to Crowdin profiles 2026-01-19 02:28:35 +07:00
zarzet a650632c4e feat: add translators section in about page and fix ARB locale format 2026-01-19 02:25:30 +07:00
zarzet 3c118f74e4 chore: rename ARB files and add Spanish/Portuguese languages 2026-01-19 02:17:32 +07:00
zarzet bc3055f6e1 chore: update supported locales 2026-01-19 02:14:54 +07:00
zarzet 7c86ae0b7e feat: add quick search provider switcher and genre/label for extensions
- Add dropdown menu in search bar for instant provider switching
- Support genre & label metadata for extension downloads
- Bump version to 3.1.2 (build 61)
2026-01-19 02:14:52 +07:00
zarzet 595bfb2711 feat: add button setting type for extension actions
- Add SettingTypeButton for action buttons in extension settings
- Add Action field to ExtensionSetting for JS function name
- Update extension detail page UI to render button settings
- Add InvokeAction method to execute button actions
2026-01-19 02:14:52 +07:00
zarzet 5f39a3d52f fix: use CollapseMode.none for smoother header animation 2026-01-19 02:14:50 +07:00
zarzet e7077781e6 feat: add genre and label metadata to FLAC downloads
- Fetch genre and label from Deezer album API before download
- Add GENRE, ORGANIZATION (label), and COPYRIGHT tags to FLAC files
- Update Go Metadata struct with new fields
- Add GetDeezerExtendedMetadata export function for Flutter
- Register platform channel handlers for Android and iOS
- Pass genre/label through download flow to all services (Tidal/Qobuz/Amazon)
2026-01-19 02:14:50 +07:00
zarzet 42d15db4ca fix: show 'Artist' label for artist items instead of 'Album'
Fixed fallback subtitle in _CollectionItemWidget for artist search results
2026-01-19 02:14:49 +07:00
zarzet c2599981d6 fix: Clear All now hides ALL downloads, not just visible 10
Previously only hid uniqueItems (max 10 visible), now hides all downloadItems
2026-01-19 02:14:48 +07:00
zarzet a1647a41ff fix: use ref.watch for hiddenDownloadIds reactivity
Show All Downloads button now updates immediately without restart
2026-01-19 02:14:47 +07:00
zarzet bf2fc7702b chore: remove debug print statements from recent_access_provider 2026-01-19 02:14:46 +07:00
zarzet f814408702 style: reduce AppBar title font size to 16px for long titles 2026-01-19 02:14:45 +07:00
zarzet 6b1958bfd0 feat: show 'Show All Downloads' button when recents is empty
- Button appears when all items are cleared/hidden
- Clicking resets hidden downloads list
- Clear All button only shows when there are items
- Empty state with visibility_off icon
2026-01-19 02:14:29 +07:00
zarzet bc120ffa76 feat: allow hiding downloads from recents without deleting files
- Add hiddenDownloadIds set to RecentAccessState
- X button on download items hides from recents (not delete file)
- Hidden IDs persisted in SharedPreferences
- Clear All also clears hidden downloads list
- Single track shows as Track, 2+ tracks shows as Album in recents
2026-01-19 02:14:27 +07:00
zarzet 5ea454a0b0 fix: downloaded album navigation from recents 2026-01-19 02:14:26 +07:00
zarzet da574f895c feat: v3.1.2 - MP3 option, dominant color headers, sticky titles, disc separation
Added:
- MP3 quality option with FLAC-to-MP3 conversion (320kbps)
- Dominant color header backgrounds on detail screens
- Spotify-style sticky title on scroll (album, playlist, artist screens)
- Disc separation for multi-disc albums
- Album grouping in recent downloads
- 50% screen width cover art

Changed:
- Improved FFmpeg FLAC-to-MP3 conversion workflow
- AppBar uses theme surface color when collapsed

Fixed:
- Empty catch blocks with proper comments
- Russian plural forms (ICU syntax)

Dependencies:
- Added palette_generator ^0.3.3+4
2026-01-19 02:13:53 +07:00
Zarz Eleutherius 1c445e91d9 Merge pull request #77 from zarzet/l10n_dev
New Crowdin updates
2026-01-19 02:12:44 +07:00
Zarz Eleutherius 5d03eb0656 New translations app_en.arb (Portuguese) 2026-01-19 02:11:51 +07:00
Zarz Eleutherius becb6845a6 Merge pull request #68 from zarzet/l10n_dev
New Crowdin updates
2026-01-19 00:48:32 +07:00
Zarz Eleutherius be3ee3b216 New translations app_en.arb (Chinese Traditional) 2026-01-19 00:29:39 +07:00
Zarz Eleutherius 3747674968 New translations app_en.arb (Russian) 2026-01-19 00:29:37 +07:00
Zarz Eleutherius ff9d088c5f New translations app_en.arb (German) 2026-01-19 00:29:34 +07:00
Zarz Eleutherius 12db11d559 New translations app_en.arb (Spanish) 2026-01-19 00:29:33 +07:00
Zarz Eleutherius 7e1aca33a5 New translations app_en.arb (Hindi) 2026-01-18 03:42:29 +07:00
Zarz Eleutherius 07a1c68354 New translations app_en.arb (Indonesian) 2026-01-18 03:42:28 +07:00
Zarz Eleutherius f4d7c6531f New translations app_en.arb (Chinese Traditional) 2026-01-18 03:42:27 +07:00
Zarz Eleutherius e9ca054682 New translations app_en.arb (Chinese Simplified) 2026-01-18 03:42:27 +07:00
Zarz Eleutherius 1069bdd0d8 New translations app_en.arb (Portuguese) 2026-01-18 03:42:25 +07:00
Zarz Eleutherius ff882a58d7 New translations app_en.arb (Dutch) 2026-01-18 03:42:25 +07:00
Zarz Eleutherius dddc8c3d94 New translations app_en.arb (Korean) 2026-01-18 03:42:24 +07:00
Zarz Eleutherius 720525b67b New translations app_en.arb (German) 2026-01-18 03:42:22 +07:00
Zarz Eleutherius cc12f63d36 New translations app_en.arb (Spanish) 2026-01-18 03:42:21 +07:00
Zarz Eleutherius 5c67553596 New translations app_en.arb (French) 2026-01-18 03:42:20 +07:00
zarzet 0ccda8db58 fix: locale format and translation updates 2026-01-18 03:27:43 +07:00
zarzet 6d7b89b881 v3.1.1: Lyrics caching, duration matching, Deezer cover upgrade, live extension search, Russian language, fix race conditions and scroll exceptions 2026-01-18 03:15:20 +07:00
Zarz Eleutherius 47777b4343 Merge pull request #65 from zarzet/l10n_dev
New Crowdin updates
2026-01-18 01:46:48 +07:00
Zarz Eleutherius 2eb1d2a65d New translations app_en.arb (Russian) 2026-01-18 01:45:39 +07:00
Zarz Eleutherius ce057c6473 New translations app_en.arb (Japanese) 2026-01-18 01:45:36 +07:00
Zarz Eleutherius 46cfe8b632 Merge pull request #58 from zarzet/l10n_dev
New Crowdin updates
2026-01-17 22:01:48 +07:00
zarzet 2e5eff6e3d chore: add cursor files to gitignore 2026-01-17 10:10:56 +07:00
zarzet dd506efeb6 chore: remove .cursorignore from tracking 2026-01-17 10:10:20 +07:00
zarzet 8d92d22fda refactor: more code cleanup 2026-01-17 10:04:21 +07:00
zarzet b99764b1ad refactor: cleanup unused code and imports 2026-01-17 09:50:00 +07:00
zarzet 621582cf11 refactor: additional code cleanup 2026-01-17 09:36:05 +07:00
zarzet b96233f90b refactor: code cleanup and improvements 2026-01-17 09:07:29 +07:00
Zarz Eleutherius 65e21a421d New translations app_en.arb (Hindi) 2026-01-17 05:23:55 +07:00
Zarz Eleutherius 87b33dda7e New translations app_en.arb (Indonesian) 2026-01-17 05:23:54 +07:00
Zarz Eleutherius 2f097c8f6c New translations app_en.arb (Chinese Traditional) 2026-01-17 05:23:53 +07:00
Zarz Eleutherius 8cbdea1417 New translations app_en.arb (Chinese Simplified) 2026-01-17 05:23:52 +07:00
Zarz Eleutherius 48bdd154f6 New translations app_en.arb (Russian) 2026-01-17 05:23:51 +07:00
Zarz Eleutherius ae0e157c34 New translations app_en.arb (Portuguese) 2026-01-17 05:23:50 +07:00
Zarz Eleutherius 53fcdd9a47 New translations app_en.arb (Dutch) 2026-01-17 05:23:49 +07:00
Zarz Eleutherius 3d6be3bf92 New translations app_en.arb (Korean) 2026-01-17 05:23:48 +07:00
Zarz Eleutherius 2d7fba3f52 New translations app_en.arb (Japanese) 2026-01-17 05:23:47 +07:00
Zarz Eleutherius e02d8ff2cd New translations app_en.arb (German) 2026-01-17 05:23:46 +07:00
Zarz Eleutherius f8cee25958 New translations app_en.arb (Spanish) 2026-01-17 05:23:45 +07:00
Zarz Eleutherius 99c133aae1 New translations app_en.arb (French) 2026-01-17 05:23:45 +07:00
135 changed files with 26125 additions and 8944 deletions
+4
View File
@@ -0,0 +1,4 @@
github: zarzet
ko_fi: zarzet
buy_me_a_coffee: zarzet
+122
View File
@@ -412,3 +412,125 @@ jobs:
prerelease: ${{ needs.get-version.outputs.is_prerelease == 'true' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
notify-telegram:
runs-on: ubuntu-latest
needs: [get-version, create-release]
if: ${{ needs.get-version.outputs.is_prerelease != 'true' }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Download Android APK
uses: actions/download-artifact@v4
with:
name: android-apk
path: ./release
- name: Download iOS IPA
uses: actions/download-artifact@v4
with:
name: ios-ipa
path: ./release
- name: Extract changelog for version
id: changelog
run: |
VERSION=${{ needs.get-version.outputs.version }}
VERSION_NUM=${VERSION#v}
# Extract changelog, limit to ~2500 chars for Telegram (4096 limit minus message overhead)
FULL_CHANGELOG=$(sed -n "/^## \[$VERSION_NUM\]/,/^## \[/{ /^## \[$VERSION_NUM\]/d; /^## \[/d; p; }" CHANGELOG.md | sed '/^---$/d')
if [ -z "$FULL_CHANGELOG" ]; then
CHANGELOG="See release notes on GitHub for details."
else
# Convert GitHub Markdown to Telegram HTML:
# - **text** → <b>text</b>
# - `code` → <code>code</code>
# - ### Header → <b>Header</b>
# - Escape HTML special chars first
CHANGELOG=$(echo "$FULL_CHANGELOG" | \
sed 's/&/\&amp;/g' | \
sed 's/</\&lt;/g' | \
sed 's/>/\&gt;/g' | \
sed 's/`\([^`]*\)`/<code>\1<\/code>/g' | \
sed 's/\*\*\([^*]*\)\*\*/<b>\1<\/b>/g' | \
sed 's/^### \(.*\)$/<b>\1<\/b>/g' | \
sed 's/^## \(.*\)$/<b>\1<\/b>/g' | \
sed 's/^- /• /g' | \
sed 's/^ - / ◦ /g')
# Take first 2500 characters, then cut at last complete line
CHANGELOG=$(echo "$CHANGELOG" | head -c 2500 | sed '$d')
# Check if truncated
FULL_LEN=${#FULL_CHANGELOG}
if [ $FULL_LEN -gt 2500 ]; then
CHANGELOG="${CHANGELOG}"$'\n\n... (see full changelog on GitHub)'
fi
fi
echo "$CHANGELOG" > /tmp/changelog.txt
- name: Send to Telegram Channel
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHANNEL_ID: ${{ secrets.TELEGRAM_CHANNEL_ID }}
run: |
VERSION=${{ needs.get-version.outputs.version }}
CHANGELOG=$(cat /tmp/changelog.txt)
# Find APK files
ARM64_APK=$(find ./release -name "*arm64*.apk" | head -1)
ARM32_APK=$(find ./release -name "*arm32*.apk" | head -1)
# Prepare message with changelog (HTML format)
printf '%s\n' \
"<b>SpotiFLAC Mobile ${VERSION} Released!</b>" \
"" \
"<b>What's New:</b>" \
"${CHANGELOG}" \
"" \
"<a href=\"https://github.com/${{ github.repository }}/releases/tag/${VERSION}\">View Release Notes</a>" \
> /tmp/telegram_message.txt
MESSAGE=$(cat /tmp/telegram_message.txt)
# Send message first (using HTML parse mode)
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-d chat_id="${TELEGRAM_CHANNEL_ID}" \
-d text="${MESSAGE}" \
-d parse_mode="HTML" \
-d disable_web_page_preview="true"
# Upload arm64 APK to channel
if [ -f "$ARM64_APK" ]; then
echo "Uploading arm64 APK to Telegram..."
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
-F document=@"${ARM64_APK}" \
-F caption="SpotiFLAC ${VERSION} - arm64 (recommended)"
fi
# Upload arm32 APK to channel
if [ -f "$ARM32_APK" ]; then
echo "Uploading arm32 APK to Telegram..."
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
-F document=@"${ARM32_APK}" \
-F caption="SpotiFLAC ${VERSION} - arm32"
fi
# Upload iOS IPA to channel
IOS_IPA=$(find ./release -name "*ios*.ipa" | head -1)
if [ -f "$IOS_IPA" ]; then
echo "Uploading iOS IPA to Telegram..."
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
-F document=@"${IOS_IPA}" \
-F caption="SpotiFLAC ${VERSION} - iOS (unsigned, sideload required)"
fi
echo "Telegram notification sent!"
+2
View File
@@ -6,6 +6,8 @@ Thumbs.db
.idea/
.vscode/
*.iml
.cursorignore
.cursorrules
# Kiro specs (development only)
.kiro/
+370 -1649
View File
File diff suppressed because it is too large Load Diff
+133
View File
@@ -0,0 +1,133 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, caste, color, religion, or sexual
identity and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the overall
community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or advances of
any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email address,
without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official email address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
**[zarzet](https://github.com/zarzet)**.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series of
actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or permanent
ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within the
community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.1, available at
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
Community Impact Guidelines were inspired by
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
For answers to common questions about this code of conduct, see the FAQ at
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
[https://www.contributor-covenant.org/translations][translations].
[homepage]: https://www.contributor-covenant.org
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
[Mozilla CoC]: https://github.com/mozilla/diversity
[FAQ]: https://www.contributor-covenant.org/faq
[translations]: https://www.contributor-covenant.org/translations
+268
View File
@@ -0,0 +1,268 @@
# Contributing to SpotiFLAC
First off, thank you for considering contributing to SpotiFLAC! 🎉
This document provides guidelines and steps for contributing. Following these guidelines helps maintain code quality and ensures a smooth collaboration process.
## Table of Contents
- [Code of Conduct](#code-of-conduct)
- [How Can I Contribute?](#how-can-i-contribute)
- [Reporting Bugs](#reporting-bugs)
- [Suggesting Features](#suggesting-features)
- [Code Contributions](#code-contributions)
- [Translations](#translations)
- [Development Setup](#development-setup)
- [Project Structure](#project-structure)
- [Coding Guidelines](#coding-guidelines)
- [Commit Guidelines](#commit-guidelines)
- [Pull Request Process](#pull-request-process)
## Code of Conduct
This project and everyone participating in it is governed by our [Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior to the project maintainers.
## How Can I Contribute?
### Reporting Bugs
Before creating bug reports, please check the [existing issues](https://github.com/zarzet/SpotiFLAC-Mobile/issues) to avoid duplicates.
When creating a bug report, please use the bug report template and include:
- **Clear and descriptive title**
- **Steps to reproduce** the issue
- **Expected behavior** vs **actual behavior**
- **Screenshots or screen recordings** if applicable
- **Device information** (model, OS version)
- **App version**
- **Logs** from Settings > About > View Logs
### Suggesting Features
Feature requests are welcome! Please use the feature request template and:
- **Check existing issues** to avoid duplicates
- **Describe the feature** clearly
- **Explain the use case** - why would this be useful?
- **Consider the scope** - is this a small enhancement or a major feature?
### Code Contributions
1. **Fork the repository** and create your branch from `dev`
2. **Make your changes** following our coding guidelines
3. **Test your changes** thoroughly
4. **Submit a pull request** to the `dev` branch
### Translations
We use [Crowdin](https://crowdin.com/project/spotiflac-mobile) for translations. To contribute:
1. Visit our [Crowdin project](https://crowdin.com/project/spotiflac-mobile)
2. Select your language or request a new one
3. Start translating!
Translation files are located in `lib/l10n/arb/`.
## Development Setup
### Prerequisites
- **Flutter SDK** 3.10.0 or higher
- **Dart SDK** 3.10.0 or higher
- **Android Studio** or **VS Code** with Flutter extensions
- **Git**
### Getting Started
1. **Clone your fork**
```bash
git clone https://github.com/YOUR_USERNAME/SpotiFLAC-Mobile.git
cd SpotiFLAC-Mobile
```
2. **Add upstream remote**
```bash
git remote add upstream https://github.com/zarzet/SpotiFLAC-Mobile.git
```
3. **Install dependencies**
```bash
flutter pub get
```
4. **Generate code** (for Riverpod, JSON serialization, etc.)
```bash
dart run build_runner build --delete-conflicting-outputs
```
5. **Run the app**
```bash
flutter run
```
### Building
```bash
# Debug build
flutter build apk --debug
# Release build
flutter build apk --release
```
## Project Structure
```
lib/
├── l10n/ # Localization files
│ └── arb/ # ARB translation files
├── models/ # Data models
├── providers/ # Riverpod providers
├── screens/ # UI screens
│ └── settings/ # Settings sub-screens
├── services/ # Business logic services
├── theme/ # App theming
├── utils/ # Utility functions
├── widgets/ # Reusable widgets
├── app.dart # App configuration
└── main.dart # Entry point
```
## Coding Guidelines
### General
- Follow [Effective Dart](https://dart.dev/effective-dart) guidelines
- Use meaningful variable and function names
- Keep functions small and focused
- Add comments for complex logic
### Formatting
- Use `dart format` before committing
- Maximum line length: 80 characters
- Use trailing commas for better formatting
```bash
dart format .
```
### Linting
Ensure your code passes all lints:
```bash
flutter analyze
```
### State Management
We use **Riverpod** for state management. Follow these patterns:
```dart
// Use code generation with riverpod_annotation
@riverpod
class MyNotifier extends _$MyNotifier {
@override
MyState build() => MyState();
// Methods to update state
}
```
### Localization
All user-facing strings should be localized:
```dart
// Good
Text(AppLocalizations.of(context)!.downloadComplete)
// Bad
Text('Download Complete')
```
To add new strings:
1. Add the key to `lib/l10n/arb/app_en.arb`
2. Run `flutter gen-l10n`
## Commit Guidelines
We follow [Conventional Commits](https://www.conventionalcommits.org/):
```
<type>(<scope>): <description>
[optional body]
[optional footer(s)]
```
### Types
- `feat`: New feature
- `fix`: Bug fix
- `docs`: Documentation changes
- `style`: Code style changes (formatting, etc.)
- `refactor`: Code refactoring
- `perf`: Performance improvements
- `test`: Adding or updating tests
- `chore`: Maintenance tasks
### Examples
```
feat(download): add batch download support
fix(ui): resolve overflow on small screens
docs: update contributing guidelines
chore(deps): update flutter_riverpod to 3.1.0
```
## Pull Request Process
1. **Update your fork**
```bash
git fetch upstream
git rebase upstream/dev
```
2. **Create a feature branch**
```bash
git checkout -b feat/my-new-feature
```
3. **Make your changes** and commit following our guidelines
4. **Push to your fork**
```bash
git push origin feat/my-new-feature
```
5. **Create a Pull Request**
- Target the `dev` branch
- Fill in the PR template
- Link related issues
6. **Address review feedback**
- Make requested changes
- Push additional commits
- Request re-review when ready
### PR Requirements
- [ ] Code follows project conventions
- [ ] All tests pass
- [ ] No new linting errors
- [ ] Documentation updated (if needed)
- [ ] Commit messages follow guidelines
- [ ] PR description is clear and complete
## Questions?
If you have questions, feel free to:
- Open a [Discussion](https://github.com/zarzet/SpotiFLAC-Mobile/discussions)
- Check existing [Issues](https://github.com/zarzet/SpotiFLAC-Mobile/issues)
Thank you for contributing! 💚
+26 -12
View File
@@ -1,12 +1,12 @@
[![GitHub All Releases](https://img.shields.io/github/downloads/zarzet/SpotiFLAC-Mobile/total?style=for-the-badge)](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/e1c527eacb6f5ce527af214a75aab8da060c2afc629825fff24af858439e7e6b)
[![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/3257155286587a3596ad5d4380d4576a684aa3d37a5b19a615914a845fbe57f3)
[![Crowdin](https://img.shields.io/badge/HELP%20TRANSLATE%20ON-CROWDIN-%2321252b?style=for-the-badge&logo=crowdin)](https://crowdin.com/project/spotiflac-mobile)
<div align="center">
<img src="icon.png" width="128" />
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required.
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music — no account required.
![Android](https://img.shields.io/badge/Android-7.0%2B-3DDC84?style=for-the-badge&logo=android&logoColor=white)
![iOS](https://img.shields.io/badge/iOS-14.0%2B-000000?style=for-the-badge&logo=apple&logoColor=white)
@@ -26,12 +26,12 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account
## Search Source
SpotiFLAC supports two search sources:
SpotiFLAC supports multiple search sources for finding music metadata:
| Source | Setup |
|--------|-------|
| **Deezer** (Default) | No setup required |
| **Spotify** | Install **Spotify Web** extension from the Store, or use your own [Spotify Developer](https://developer.spotify.com) Client ID & Secret in Settings |
| **Extensions** | Install additional search providers from the Store |
## Extensions
@@ -50,7 +50,21 @@ Want to create your own extension? Check out the [Extension Development Guide](h
## Other project
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
> **Note:** Currently unavailable because the GitHub account is suspended. Alternatively, use [SpotiFLAC-Next](https://github.com/spotiverse/SpotiFLAC-Next) until the original is restored.
## Telegram
<p align="center">
<a href="https://t.me/spotiflac">
<img src="https://img.shields.io/badge/Telegram-Channel-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Channel">
</a>
<a href="https://t.me/spotiflacchat">
<img src="https://img.shields.io/badge/Telegram-Community-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Community">
</a>
</p>
## FAQ
@@ -60,25 +74,25 @@ A: The track may not be available on Tidal, Qobuz, or Amazon Music. Try enabling
**Q: Why are some tracks downloading in lower quality?**
A: Quality depends on what's available from the streaming service. Tidal offers up to 24-bit/192kHz, Qobuz up to 24-bit/192kHz, and Amazon up to 24-bit/48kHz. The app automatically selects the best available quality.
**Q: Can I download my Spotify playlists?**
A: Yes! Just paste the Spotify playlist URL in the search bar. The app will fetch all tracks and queue them for download.
**Q: Can I download playlists?**
A: Yes! Just paste the playlist URL in the search bar. The app will fetch all tracks and queue them for download.
**Q: Why do I need to grant storage permission?**
A: The app needs permission to save downloaded files to your device. On Android 13+, you may need to grant "All files access" in Settings > Apps > SpotiFLAC > Permissions.
**Q: How do I download Daily Mix or Discover Weekly?**
A: Install the **Spotify Web** extension from the Store. This extension can access personalized playlists that aren't available through the public API.
**Q: Is this app safe?**
A: Yes, the app is open source and you can verify the code yourself. Each release is scanned with VirusTotal (see badge at top of README).
[![Ko-fi](https://img.shields.io/badge/Ko--fi-Support%20Me-FF5E5B?style=for-the-badge&logo=ko-fi&logoColor=white)](https://ko-fi.com/zarzet)
**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.
## Disclaimer
This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement.
**SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Spotify, Tidal, Qobuz, Amazon Music, or any other streaming service.
**SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Tidal, Qobuz, Amazon Music, Deezer, or any other streaming service.
The application is purely a user interface that facilitates communication between your device and existing third-party services.
You are solely responsible for:
1. Ensuring your use of this software complies with your local laws.
@@ -139,6 +139,28 @@ class MainActivity: FlutterActivity() {
}
result.success(response)
}
"checkDuplicatesBatch" -> {
val outputDir = call.argument<String>("output_dir") ?: ""
val tracksJson = call.argument<String>("tracks") ?: "[]"
val response = withContext(Dispatchers.IO) {
Gobackend.checkDuplicatesBatch(outputDir, tracksJson)
}
result.success(response)
}
"preBuildDuplicateIndex" -> {
val outputDir = call.argument<String>("output_dir") ?: ""
withContext(Dispatchers.IO) {
Gobackend.preBuildDuplicateIndex(outputDir)
}
result.success(null)
}
"invalidateDuplicateIndex" -> {
val outputDir = call.argument<String>("output_dir") ?: ""
withContext(Dispatchers.IO) {
Gobackend.invalidateDuplicateIndex(outputDir)
}
result.success(null)
}
"buildFilename" -> {
val template = call.argument<String>("template") ?: ""
val metadata = call.argument<String>("metadata") ?: "{}"
@@ -158,8 +180,9 @@ class MainActivity: FlutterActivity() {
val spotifyId = call.argument<String>("spotify_id") ?: ""
val trackName = call.argument<String>("track_name") ?: ""
val artistName = call.argument<String>("artist_name") ?: ""
val durationMs = call.argument<Int>("duration_ms")?.toLong() ?: 0L
val response = withContext(Dispatchers.IO) {
Gobackend.fetchLyrics(spotifyId, trackName, artistName)
Gobackend.fetchLyrics(spotifyId, trackName, artistName, durationMs)
}
result.success(response)
}
@@ -168,8 +191,9 @@ class MainActivity: FlutterActivity() {
val trackName = call.argument<String>("track_name") ?: ""
val artistName = call.argument<String>("artist_name") ?: ""
val filePath = call.argument<String>("file_path") ?: ""
val durationMs = call.argument<Int>("duration_ms")?.toLong() ?: 0L
val response = withContext(Dispatchers.IO) {
Gobackend.getLyricsLRC(spotifyId, trackName, artistName, filePath)
Gobackend.getLyricsLRC(spotifyId, trackName, artistName, filePath, durationMs)
}
result.success(response)
}
@@ -282,6 +306,13 @@ class MainActivity: FlutterActivity() {
}
result.success(response)
}
"getDeezerExtendedMetadata" -> {
val trackId = call.argument<String>("track_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getDeezerExtendedMetadata(trackId)
}
result.success(response)
}
"convertSpotifyToDeezer" -> {
val resourceType = call.argument<String>("resource_type") ?: ""
val spotifyId = call.argument<String>("spotify_id") ?: ""
@@ -297,6 +328,43 @@ class MainActivity: FlutterActivity() {
}
result.success(response)
}
"checkAvailabilityFromDeezerID" -> {
val deezerTrackId = call.argument<String>("deezer_track_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.checkAvailabilityFromDeezerID(deezerTrackId)
}
result.success(response)
}
"checkAvailabilityByPlatformID" -> {
val platform = call.argument<String>("platform") ?: ""
val entityType = call.argument<String>("entity_type") ?: ""
val entityId = call.argument<String>("entity_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.checkAvailabilityByPlatformID(platform, entityType, entityId)
}
result.success(response)
}
"getSpotifyIDFromDeezerTrack" -> {
val deezerTrackId = call.argument<String>("deezer_track_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getSpotifyIDFromDeezerTrack(deezerTrackId)
}
result.success(response)
}
"getTidalURLFromDeezerTrack" -> {
val deezerTrackId = call.argument<String>("deezer_track_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getTidalURLFromDeezerTrack(deezerTrackId)
}
result.success(response)
}
"getAmazonURLFromDeezerTrack" -> {
val deezerTrackId = call.argument<String>("deezer_track_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getAmazonURLFromDeezerTrack(deezerTrackId)
}
result.success(response)
}
// Log methods
"getLogs" -> {
val response = withContext(Dispatchers.IO) {
@@ -436,6 +504,14 @@ class MainActivity: FlutterActivity() {
}
result.success(null)
}
"invokeExtensionAction" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val actionName = call.argument<String>("action") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.invokeExtensionActionJSON(extensionId, actionName)
}
result.success(response)
}
"searchTracksWithExtensions" -> {
val query = call.argument<String>("query") ?: ""
val limit = call.argument<Int>("limit") ?: 20
@@ -451,6 +527,14 @@ class MainActivity: FlutterActivity() {
}
result.success(response)
}
"enrichTrackWithExtension" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val trackJson = call.argument<String>("track") ?: "{}"
val response = withContext(Dispatchers.IO) {
Gobackend.enrichTrackWithExtensionJSON(extensionId, trackJson)
}
result.success(response)
}
"removeExtension" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
withContext(Dispatchers.IO) {
@@ -661,6 +745,21 @@ class MainActivity: FlutterActivity() {
}
result.success(null)
}
// Extension Home Feed (Explore)
"getExtensionHomeFeed" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getExtensionHomeFeedJSON(extensionId)
}
result.success(response)
}
"getExtensionBrowseCategories" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getExtensionBrowseCategoriesJSON(extensionId)
}
result.success(response)
}
else -> result.notImplemented()
}
} catch (e: Exception) {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 259 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 81 KiB

+134 -7
View File
@@ -42,17 +42,27 @@ class FFmpegServiceIOS {
}
/// Convert FLAC to MP3
static Future<String?> convertFlacToMp3(String inputPath, {String bitrate = '320k'}) async {
final dir = File(inputPath).parent.path;
final baseName = inputPath.split(Platform.pathSeparator).last.replaceAll('.flac', '');
final outputDir = '$dir${Platform.pathSeparator}MP3';
await Directory(outputDir).create(recursive: true);
final outputPath = '$outputDir${Platform.pathSeparator}$baseName.mp3';
/// If deleteOriginal is true, deletes the FLAC file after conversion
static Future<String?> convertFlacToMp3(
String inputPath, {
String bitrate = '320k',
bool deleteOriginal = true,
}) async {
// Convert in same folder, just change extension
final outputPath = inputPath.replaceAll('.flac', '.mp3');
final command = '-i "$inputPath" -codec:a libmp3lame -b:a $bitrate -map 0:a -map_metadata 0 -id3v2_version 3 "$outputPath" -y';
final result = await _execute(command);
if (result.success) return outputPath;
if (result.success) {
// Delete original FLAC if requested
if (deleteOriginal) {
try {
await File(inputPath).delete();
} catch (_) {}
}
return outputPath;
}
_log.e('FLAC to MP3 conversion failed: ${result.output}');
return null;
}
@@ -177,6 +187,123 @@ class FFmpegServiceIOS {
return null;
}
/// Embed metadata and cover art to MP3 file using ID3v2 tags
/// Returns the file path on success, null on failure
static Future<String?> embedMetadataToMp3({
required String mp3Path,
String? coverPath,
Map<String, String>? metadata,
}) async {
final tempOutput = '$mp3Path.tmp';
final StringBuffer cmdBuffer = StringBuffer();
cmdBuffer.write('-i "$mp3Path" ');
if (coverPath != null) {
cmdBuffer.write('-i "$coverPath" ');
}
cmdBuffer.write('-map 0:a ');
if (coverPath != null) {
cmdBuffer.write('-map 1:0 ');
cmdBuffer.write('-c:v:0 copy ');
cmdBuffer.write('-id3v2_version 3 ');
cmdBuffer.write('-metadata:s:v title="Album cover" ');
cmdBuffer.write('-metadata:s:v comment="Cover (front)" ');
}
cmdBuffer.write('-c:a copy ');
if (metadata != null) {
// Convert FLAC/Vorbis tags to ID3v2 tags for MP3
final id3Metadata = _convertToId3Tags(metadata);
id3Metadata.forEach((key, value) {
final sanitizedValue = value.replaceAll('"', '\\"');
cmdBuffer.write('-metadata $key="$sanitizedValue" ');
});
}
cmdBuffer.write('-id3v2_version 3 "$tempOutput" -y');
final command = cmdBuffer.toString();
_log.d('Executing FFmpeg MP3 embed command: $command');
final result = await _execute(command);
if (result.success) {
try {
await File(mp3Path).delete();
await File(tempOutput).rename(mp3Path);
_log.d('MP3 metadata embedded successfully');
return mp3Path;
} catch (e) {
_log.e('Failed to replace MP3 file after metadata embed: $e');
return null;
}
}
try {
final tempFile = File(tempOutput);
if (await tempFile.exists()) {
await tempFile.delete();
}
} catch (_) {}
_log.e('MP3 Metadata/Cover embed failed: ${result.output}');
return null;
}
/// Convert FLAC/Vorbis comment tags to ID3v2 compatible tags
static Map<String, String> _convertToId3Tags(Map<String, String> vorbisMetadata) {
final id3Map = <String, String>{};
for (final entry in vorbisMetadata.entries) {
final key = entry.key.toUpperCase();
final value = entry.value;
// Map Vorbis comments to ID3v2 frame names
switch (key) {
case 'TITLE':
id3Map['title'] = value;
break;
case 'ARTIST':
id3Map['artist'] = value;
break;
case 'ALBUM':
id3Map['album'] = value;
break;
case 'ALBUMARTIST':
id3Map['album_artist'] = value;
break;
case 'TRACKNUMBER':
case 'TRACK':
id3Map['track'] = value;
break;
case 'DISCNUMBER':
case 'DISC':
id3Map['disc'] = value;
break;
case 'DATE':
case 'YEAR':
id3Map['date'] = value;
break;
case 'ISRC':
id3Map['TSRC'] = value; // ID3v2 ISRC frame
break;
case 'LYRICS':
case 'UNSYNCEDLYRICS':
id3Map['lyrics'] = value;
break;
default:
// Pass through other tags as-is
id3Map[key.toLowerCase()] = value;
}
}
return id3Map;
}
/// Check if FFmpeg is available
static Future<bool> isAvailable() async {
try {
+17 -1
View File
@@ -1,3 +1,19 @@
files:
- source: /lib/l10n/arb/app_en.arb
translation: /lib/l10n/arb/app_%locale_with_underscore%.arb
translation: /lib/l10n/arb/app_%locale%.arb
languages_mapping:
locale:
# Short codes for single-variant languages
de: de
es: es
fr: fr
hi: hi
id: id
ja: ja
ko: ko
nl: nl
pt: pt
ru: ru
# Full codes for Chinese variants
zh-CN: zh_CN
zh-TW: zh_TW
+31 -61
View File
@@ -1,8 +1,8 @@
package gobackend
import (
"context"
"bufio"
"context"
"encoding/base64"
"encoding/json"
"errors"
@@ -17,20 +17,18 @@ import (
"time"
)
// AmazonDownloader handles Amazon Music downloads using DoubleDouble service (same as PC)
type AmazonDownloader struct {
client *http.Client
regions []string // us, eu regions for DoubleDouble service
lastAPICallTime time.Time // Rate limiting: track last API call
apiCallCount int // Rate limiting: counter per minute
apiCallResetTime time.Time // Rate limiting: reset time
regions []string
lastAPICallTime time.Time
apiCallCount int
apiCallResetTime time.Time
}
var (
// Global Amazon downloader instance for connection reuse
globalAmazonDownloader *AmazonDownloader
amazonDownloaderOnce sync.Once
amazonRateLimitMu sync.Mutex // Mutex for rate limiting
amazonRateLimitMu sync.Mutex
)
// DoubleDoubleSubmitResponse is the response from DoubleDouble submit endpoint
@@ -39,7 +37,6 @@ type DoubleDoubleSubmitResponse struct {
ID string `json:"id"`
}
// DoubleDoubleStatusResponse is the response from DoubleDouble status endpoint
type DoubleDoubleStatusResponse struct {
Status string `json:"status"`
FriendlyStatus string `json:"friendlyStatus"`
@@ -50,22 +47,18 @@ type DoubleDoubleStatusResponse struct {
} `json:"current"`
}
// amazonArtistsMatch checks if the artist names are similar enough
func amazonArtistsMatch(expectedArtist, foundArtist string) bool {
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
// Exact match
if normExpected == normFound {
return true
}
// Check if one contains the other
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
return true
}
// Check first artist (before comma or feat)
expectedFirst := strings.Split(normExpected, ",")[0]
expectedFirst = strings.Split(expectedFirst, " feat")[0]
expectedFirst = strings.Split(expectedFirst, " ft.")[0]
@@ -80,13 +73,10 @@ func amazonArtistsMatch(expectedArtist, foundArtist string) bool {
return true
}
// Check if first artist is contained in the other
if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) {
return true
}
// If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean),
// assume they're the same artist with different transliteration
expectedASCII := amazonIsASCIIString(expectedArtist)
foundASCII := amazonIsASCIIString(foundArtist)
if expectedASCII != foundASCII {
@@ -97,7 +87,6 @@ func amazonArtistsMatch(expectedArtist, foundArtist string) bool {
return false
}
// amazonIsASCIIString checks if a string contains only ASCII characters
func amazonIsASCIIString(s string) bool {
for _, r := range s {
if r > 127 {
@@ -107,7 +96,6 @@ func amazonIsASCIIString(s string) bool {
return true
}
// NewAmazonDownloader creates a new Amazon downloader (returns singleton for connection reuse)
func NewAmazonDownloader() *AmazonDownloader {
amazonDownloaderOnce.Do(func() {
globalAmazonDownloader = &AmazonDownloader{
@@ -120,20 +108,17 @@ func NewAmazonDownloader() *AmazonDownloader {
}
// waitForRateLimit implements rate limiting similar to PC version
// Max 9 requests per minute with 7 second delay between requests
func (a *AmazonDownloader) waitForRateLimit() {
amazonRateLimitMu.Lock()
defer amazonRateLimitMu.Unlock()
now := time.Now()
// Reset counter every minute
if now.Sub(a.apiCallResetTime) >= time.Minute {
a.apiCallCount = 0
a.apiCallResetTime = now
}
// If we've hit the limit (9 requests per minute), wait until next minute
if a.apiCallCount >= 9 {
waitTime := time.Minute - now.Sub(a.apiCallResetTime)
if waitTime > 0 {
@@ -144,7 +129,6 @@ func (a *AmazonDownloader) waitForRateLimit() {
}
}
// Add delay between requests (7 seconds like PC version)
if !a.lastAPICallTime.IsZero() {
timeSinceLastCall := now.Sub(a.lastAPICallTime)
minDelay := 7 * time.Second
@@ -155,12 +139,10 @@ func (a *AmazonDownloader) waitForRateLimit() {
}
}
// Update tracking
a.lastAPICallTime = time.Now()
a.apiCallCount++
}
// GetAvailableAPIs returns list of available DoubleDouble regions
// Uses same service as PC version (doubledouble.top)
func (a *AmazonDownloader) GetAvailableAPIs() []string {
// DoubleDouble service regions (same as PC)
@@ -181,17 +163,13 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, _ string)
for _, region := range a.regions {
GoLog("[Amazon] Trying region: %s...\n", region)
// Build base URL for DoubleDouble service
// Decode base64 service URL (same as PC)
serviceBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly8=") // https://
serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=") // .doubledouble.top
baseURL := fmt.Sprintf("%s%s%s", string(serviceBase), region, string(serviceDomain))
// Step 1: Submit download request with rate limiting
encodedURL := url.QueryEscape(amazonURL)
submitURL := fmt.Sprintf("%s/dl?url=%s", baseURL, encodedURL)
// Apply rate limiting before request (like PC version)
a.waitForRateLimit()
req, err := http.NewRequest("GET", submitURL, nil)
@@ -301,7 +279,6 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, _ string)
if status.Status == "done" {
fmt.Println("\n[Amazon] Download ready!")
// Build download URL
fileURL := status.URL
if strings.HasPrefix(fileURL, "./") {
fileURL = fmt.Sprintf("%s/%s", baseURL, fileURL[2:])
@@ -346,7 +323,6 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, _ string)
return "", "", "", fmt.Errorf("all regions failed. Last error: %v", lastError)
}
// DownloadFile downloads a file from URL with User-Agent and progress tracking
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
ctx := context.Background()
@@ -383,7 +359,6 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
}
expectedSize := resp.ContentLength
// Set total bytes if available
if expectedSize > 0 && itemID != "" {
SetItemBytesTotal(itemID, expectedSize)
}
@@ -393,16 +368,13 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
return err
}
// Use buffered writer for better performance (256KB buffer)
bufWriter := bufio.NewWriterSize(out, 256*1024)
// Use item progress writer with buffered output
var written int64
if itemID != "" {
pw := NewItemProgressWriter(bufWriter, itemID)
written, err = io.Copy(pw, resp.Body)
} else {
// Fallback: direct copy without progress tracking
written, err = io.Copy(bufWriter, resp.Body)
}
@@ -410,7 +382,6 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
flushErr := bufWriter.Flush()
closeErr := out.Close()
// Check for any errors
if err != nil {
os.Remove(outputPath)
if isDownloadCancelled(itemID) {
@@ -451,29 +422,23 @@ type AmazonDownloadResult struct {
ISRC string
}
// downloadFromAmazon downloads a track using the request parameters
// Uses DoubleDouble service (same as PC version)
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
downloader := NewAmazonDownloader()
// Check for existing file first
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
return AmazonDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
}
// Get Amazon URL from SongLink
songlink := NewSongLinkClient()
var availability *TrackAvailability
var err error
// Check if SpotifyID is actually a Deezer ID (format: "deezer:xxxxx")
if strings.HasPrefix(req.SpotifyID, "deezer:") {
// Extract Deezer ID and use Deezer-based lookup
deezerID := strings.TrimPrefix(req.SpotifyID, "deezer:")
GoLog("[Amazon] Using Deezer ID for SongLink lookup: %s\n", deezerID)
availability, err = songlink.CheckAvailabilityFromDeezer(deezerID)
} else if req.SpotifyID != "" {
// Use Spotify ID
availability, err = songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
} else {
return AmazonDownloadResult{}, fmt.Errorf("no valid Spotify or Deezer ID provided for Amazon lookup")
@@ -487,7 +452,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
return AmazonDownloadResult{}, fmt.Errorf("track not available on Amazon Music (SongLink returned no Amazon URL)")
}
// Create output directory if needed
if req.OutputDir != "." {
if err := os.MkdirAll(req.OutputDir, 0755); err != nil {
return AmazonDownloadResult{}, fmt.Errorf("failed to create output directory: %w", err)
@@ -506,10 +470,8 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
return AmazonDownloadResult{}, fmt.Errorf("artist mismatch: expected '%s', got '%s'", req.ArtistName, artistName)
}
// Log match found
GoLog("[Amazon] Match found: '%s' by '%s'\n", trackName, artistName)
// Build filename using Spotify metadata (more accurate)
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
"title": req.TrackName,
"artist": req.ArtistName,
@@ -521,7 +483,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
filename = sanitizeFilename(filename) + ".flac"
outputPath := filepath.Join(req.OutputDir, filename)
// Check if file already exists
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
}
@@ -538,6 +499,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
req.TrackName,
req.ArtistName,
req.EmbedLyrics,
int64(req.DurationMS),
)
}()
@@ -552,8 +514,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
// Wait for parallel operations to complete
<-parallelDone
// Set progress to 100% and status to finalizing (before embedding)
// This makes the UI show "Finalizing..." while embedding happens
if req.ItemID != "" {
SetItemProgress(req.ItemID, 1.0, 0, 0)
SetItemFinalizing(req.ItemID)
@@ -564,14 +524,11 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
GoLog("[Amazon] DoubleDouble returned: %s - %s\n", artistName, trackName)
}
// Read existing metadata from downloaded file BEFORE embedding
// Amazon/DoubleDouble files often have correct track/disc numbers that we should preserve
existingMeta, metaErr := ReadMetadata(outputPath)
actualTrackNum := req.TrackNumber
actualDiscNum := req.DiscNumber
if metaErr == nil && existingMeta != nil {
// Use file metadata if it has valid track/disc numbers and request doesn't have them
if existingMeta.TrackNumber > 0 && (req.TrackNumber == 0 || req.TrackNumber == 1) {
actualTrackNum = existingMeta.TrackNumber
GoLog("[Amazon] Using track number from file: %d (request had: %d)\n", actualTrackNum, req.TrackNumber)
@@ -594,6 +551,9 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
TotalTracks: req.TotalTracks,
DiscNumber: actualDiscNum,
ISRC: req.ISRC,
Genre: req.Genre, // From Deezer album metadata
Label: req.Label, // From Deezer album metadata
Copyright: req.Copyright, // From Deezer album metadata
}
// Use cover data from parallel fetch
@@ -607,13 +567,28 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
}
// Embed lyrics from parallel fetch
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
} else {
fmt.Println("[Amazon] Lyrics embedded successfully")
lyricsMode := req.LyricsMode
if lyricsMode == "" {
lyricsMode = "embed" // default
}
if lyricsMode == "external" || lyricsMode == "both" {
GoLog("[Amazon] Saving external LRC file...\n")
if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil {
GoLog("[Amazon] Warning: failed to save LRC file: %v\n", lrcErr)
} else {
GoLog("[Amazon] LRC file saved: %s\n", lrcPath)
}
}
if lyricsMode == "embed" || lyricsMode == "both" {
GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
} else {
fmt.Println("[Amazon] Lyrics embedded successfully")
}
}
} else if req.EmbedLyrics {
fmt.Println("[Amazon] No lyrics available from parallel fetch")
@@ -621,8 +596,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
fmt.Println("[Amazon] ✓ Downloaded successfully from Amazon Music")
// Read actual quality from the downloaded FLAC file
// Amazon API doesn't provide quality info, but we can read it from the file itself
quality, err := GetAudioQuality(outputPath)
if err != nil {
GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err)
@@ -630,8 +603,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
GoLog("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
}
// Read metadata from file AFTER embedding to get accurate values
// This ensures we return what's actually in the file
finalMeta, metaReadErr := ReadMetadata(outputPath)
if metaReadErr == nil && finalMeta != nil {
GoLog("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n",
@@ -639,7 +610,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
actualTrackNum = finalMeta.TrackNumber
actualDiscNum = finalMeta.DiscNumber
if finalMeta.Date != "" {
// Use date from file if available
req.ReleaseDate = finalMeta.Date
}
}
-1
View File
@@ -52,7 +52,6 @@ func cancelDownload(itemID string) {
}
cancelMu.Unlock()
// Hide progress for cancelled items.
RemoveItemProgress(itemID)
}
+30 -25
View File
@@ -4,18 +4,19 @@ import (
"fmt"
"io"
"net/http"
"regexp"
"strings"
)
// Spotify image size codes (same as PC version)
const (
spotifySize300 = "ab67616d00001e02" // 300x300 (small)
spotifySize640 = "ab67616d0000b273" // 640x640 (medium)
spotifySizeMax = "ab67616d000082c1" // Max resolution (~2000x2000)
spotifySize300 = "ab67616d00001e02"
spotifySize640 = "ab67616d0000b273"
spotifySizeMax = "ab67616d000082c1"
)
// convertSmallToMedium upgrades 300x300 cover URL to 640x640
// Same logic as PC version for consistency
// Deezer CDN supports these sizes: 56, 250, 500, 1000, 1400, 1800
var deezerSizeRegex = regexp.MustCompile(`/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$`)
func convertSmallToMedium(imageURL string) string {
if strings.Contains(imageURL, spotifySize300) {
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
@@ -23,8 +24,6 @@ func convertSmallToMedium(imageURL string) string {
return imageURL
}
// downloadCoverToMemory downloads cover art and returns as bytes (no file creation)
// This avoids file permission issues on Android
func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
if coverURL == "" {
return nil, fmt.Errorf("no cover URL provided")
@@ -32,20 +31,19 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
GoLog("[Cover] Original URL: %s", coverURL)
// First upgrade small (300) to medium (640) - always do this
downloadURL := convertSmallToMedium(coverURL)
if downloadURL != coverURL {
GoLog("[Cover] Upgraded 300x300 → 640x640")
}
// Then upgrade to max quality if requested
if maxQuality {
maxURL := upgradeToMaxQuality(downloadURL)
if maxURL != downloadURL {
downloadURL = maxURL
GoLog("[Cover] Upgraded to max resolution (~2000x2000)")
} else {
GoLog("[Cover] Max resolution not available, using 640x640")
// Log already printed by upgradeToMaxQuality for Deezer
if strings.Contains(coverURL, "scdn.co") || strings.Contains(coverURL, "spotifycdn") {
GoLog("[Cover] Spotify: upgraded to max resolution (~2000x2000)")
}
}
}
@@ -53,7 +51,6 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
client := NewHTTPClientWithTimeout(DefaultTimeout)
// Create request with User-Agent (required by Spotify CDN)
req, err := http.NewRequest("GET", downloadURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
@@ -74,8 +71,6 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
return nil, fmt.Errorf("failed to read cover data: %w", err)
}
// Calculate approximate resolution from file size
// JPEG ~2000x2000 is typically 300-600KB, 640x640 is ~50-100KB
sizeKB := len(data) / 1024
var resolution string
if sizeKB > 200 {
@@ -90,23 +85,33 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
return data, nil
}
// upgradeToMaxQuality upgrades Spotify cover URL to maximum quality
// Same logic as PC version - directly replaces 640x640 size code with max resolution
// No HEAD verification needed - Spotify CDN always serves max resolution if available
func upgradeToMaxQuality(coverURL string) string {
// Spotify image URLs can be upgraded by changing the size parameter
// Format: https://i.scdn.co/image/ab67616d0000b273...
// ab67616d0000b273 = 640x640
// ab67616d000082c1 = Max resolution (~2000x2000)
// Spotify CDN upgrade
if strings.Contains(coverURL, spotifySize640) {
return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
}
// Deezer CDN upgrade
if strings.Contains(coverURL, "cdn-images.dzcdn.net") {
return upgradeDeezerCover(coverURL)
}
return coverURL
}
// GetCoverFromSpotify gets cover URL from Spotify metadata
func upgradeDeezerCover(coverURL string) string {
if !strings.Contains(coverURL, "cdn-images.dzcdn.net") {
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")
}
return upgraded
}
func GetCoverFromSpotify(imageURL string, maxQuality bool) string {
if imageURL == "" {
return ""
+154 -45
View File
@@ -22,27 +22,23 @@ const (
deezerCacheTTL = 10 * time.Minute
// Parallel ISRC fetching settings
deezerMaxParallelISRC = 10 // Max concurrent ISRC fetches
deezerMaxParallelISRC = 10
)
// DeezerClient handles Deezer API interactions (no auth required)
type DeezerClient struct {
httpClient *http.Client
searchCache map[string]*cacheEntry
albumCache map[string]*cacheEntry
artistCache map[string]*cacheEntry
isrcCache map[string]string // trackID -> ISRC cache
isrcCache map[string]string
cacheMu sync.RWMutex
}
// Singleton instance
var (
deezerClient *DeezerClient
deezerClientOnce sync.Once
)
// GetDeezerClient returns singleton Deezer client
func GetDeezerClient() *DeezerClient {
deezerClientOnce.Do(func() {
deezerClient = &DeezerClient{
@@ -56,7 +52,6 @@ func GetDeezerClient() *DeezerClient {
return deezerClient
}
// Deezer API response types
type deezerTrack struct {
ID int64 `json:"id"`
Title string `json:"title"`
@@ -65,7 +60,7 @@ type deezerTrack struct {
DiskNumber int `json:"disk_number"`
ISRC string `json:"isrc"`
Link string `json:"link"`
ReleaseDate string `json:"release_date"` // Sometimes at track level
ReleaseDate string `json:"release_date"`
Artist deezerArtist `json:"artist"`
Album deezerAlbumSimple `json:"album"`
Contributors []deezerArtist `json:"contributors"`
@@ -88,8 +83,8 @@ type deezerAlbumSimple struct {
CoverMedium string `json:"cover_medium"`
CoverBig string `json:"cover_big"`
CoverXL string `json:"cover_xl"`
ReleaseDate string `json:"release_date"` // Sometimes at album level
RecordType string `json:"record_type"` // album, single, ep, compile
ReleaseDate string `json:"release_date"`
RecordType string `json:"record_type"`
}
func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
@@ -113,7 +108,6 @@ func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
albumImage = track.Album.Cover
}
// Try to find release date
releaseDate := track.ReleaseDate
if releaseDate == "" {
releaseDate = track.Album.ReleaseDate
@@ -135,16 +129,25 @@ func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
}
}
type deezerGenre struct {
ID int `json:"id"`
Name string `json:"name"`
}
type deezerAlbumFull struct {
ID int64 `json:"id"`
Title string `json:"title"`
Cover string `json:"cover"`
CoverMedium string `json:"cover_medium"`
CoverBig string `json:"cover_big"`
CoverXL string `json:"cover_xl"`
ReleaseDate string `json:"release_date"`
NbTracks int `json:"nb_tracks"`
RecordType string `json:"record_type"` // album, single, ep, compile
ID int64 `json:"id"`
Title string `json:"title"`
Cover string `json:"cover"`
CoverMedium string `json:"cover_medium"`
CoverBig string `json:"cover_big"`
CoverXL string `json:"cover_xl"`
ReleaseDate string `json:"release_date"`
NbTracks int `json:"nb_tracks"`
RecordType string `json:"record_type"`
Label string `json:"label"`
Genres struct {
Data []deezerGenre `json:"data"`
} `json:"genres"`
Artist deezerArtist `json:"artist"`
Contributors []deezerArtist `json:"contributors"`
Tracks struct {
@@ -179,7 +182,6 @@ type deezerPlaylistFull struct {
} `json:"tracks"`
}
// SearchAll searches for tracks and artists on Deezer
// NOTE: ISRC is NOT fetched during search for performance - use GetTrackISRC when needed for download
func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) {
GoLog("[Deezer] SearchAll: query=%q, trackLimit=%d, artistLimit=%d\n", query, trackLimit, artistLimit)
@@ -195,8 +197,8 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
c.cacheMu.RUnlock()
result := &SearchAllResult{
Tracks: make([]TrackMetadata, 0),
Artists: make([]SearchArtistResult, 0),
Tracks: make([]TrackMetadata, 0, trackLimit),
Artists: make([]SearchArtistResult, 0, artistLimit),
}
// Search tracks - NO ISRC fetch for performance
@@ -224,11 +226,9 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
GoLog("[Deezer] Got %d tracks from API\n", len(trackResp.Data))
for _, track := range trackResp.Data {
// Convert directly without fetching ISRC - much faster
result.Tracks = append(result.Tracks, c.convertTrack(track))
}
// Search artists
artistURL := fmt.Sprintf("%s/artist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), artistLimit)
GoLog("[Deezer] Fetching artists from: %s\n", artistURL)
@@ -261,7 +261,6 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
GoLog("[Deezer] SearchAll complete: %d tracks, %d artists\n", len(result.Tracks), len(result.Artists))
// Cache result
c.cacheMu.Lock()
c.searchCache[cacheKey] = &cacheEntry{
data: result,
@@ -286,7 +285,6 @@ func (c *DeezerClient) GetTrack(ctx context.Context, trackID string) (*TrackResp
}, nil
}
// GetAlbum fetches album with tracks
// ISRC is fetched in parallel for better performance
func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResponsePayload, error) {
c.cacheMu.RLock()
@@ -313,15 +311,26 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
artistName = strings.Join(names, ", ")
}
// Extract genres as comma-separated string
var genres []string
for _, g := range album.Genres.Data {
if g.Name != "" {
genres = append(genres, g.Name)
}
}
genreStr := strings.Join(genres, ", ")
info := AlbumInfoMetadata{
TotalTracks: album.NbTracks,
Name: album.Title,
ReleaseDate: album.ReleaseDate,
Artists: artistName,
ArtistId: fmt.Sprintf("deezer:%d", album.Artist.ID),
Images: albumImage,
Genre: genreStr, // From Deezer album
Label: album.Label, // From Deezer album
}
// Fetch ISRCs in parallel
isrcMap := c.fetchISRCsParallel(ctx, album.Tracks.Data)
tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Data))
@@ -369,7 +378,6 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
return result, nil
}
// GetArtist fetches artist with albums
func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistResponsePayload, error) {
c.cacheMu.RLock()
if entry, ok := c.artistCache[artistID]; ok && !entry.isExpired() {
@@ -455,8 +463,6 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
return result, nil
}
// GetPlaylist fetches playlist with tracks
// ISRC is fetched in parallel for better performance
func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*PlaylistResponsePayload, error) {
playlistURL := fmt.Sprintf(deezerPlaylistURL, playlistID)
@@ -479,7 +485,6 @@ func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*Pla
info.Owner.Name = playlist.Title
info.Owner.Images = playlistImage
// Fetch ISRCs in parallel
isrcMap := c.fetchISRCsParallel(ctx, playlist.Tracks.Data)
tracks := make([]AlbumTrackMetadata, 0, len(playlist.Tracks.Data))
@@ -518,15 +523,11 @@ func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*Pla
}, nil
}
// SearchByISRC searches for a track by ISRC using direct endpoint
func (c *DeezerClient) SearchByISRC(ctx context.Context, isrc string) (*TrackMetadata, error) {
// Use direct ISRC endpoint (API 2.0)
// https://api.deezer.com/2.0/track/isrc:{ISRC}
directURL := fmt.Sprintf("%s/track/isrc:%s", deezerBaseURL, isrc)
var track deezerTrack
if err := c.getJSON(ctx, directURL, &track); err != nil {
// Fallback to search if direct endpoint fails
searchURL := fmt.Sprintf("%s/track?q=isrc:%s&limit=1", deezerSearchURL, isrc)
var resp struct {
Data []deezerTrack `json:"data"`
@@ -541,7 +542,6 @@ func (c *DeezerClient) SearchByISRC(ctx context.Context, isrc string) (*TrackMet
return &result, nil
}
// Check if we got a valid response (ID > 0)
if track.ID == 0 {
return nil, fmt.Errorf("no track found for ISRC: %s", isrc)
}
@@ -561,14 +561,24 @@ func (c *DeezerClient) fetchFullTrack(ctx context.Context, trackID string) (*dee
// fetchISRCsParallel fetches ISRCs for multiple tracks in parallel with caching
func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTrack) map[string]string {
result := make(map[string]string)
result := make(map[string]string, len(tracks))
var resultMu sync.Mutex
// First, check cache for existing ISRCs
var tracksToFetch []deezerTrack
var directISRCs map[string]string
c.cacheMu.RLock()
for _, track := range tracks {
trackIDStr := fmt.Sprintf("%d", track.ID)
if track.ISRC != "" {
result[trackIDStr] = track.ISRC
if _, ok := c.isrcCache[trackIDStr]; !ok {
if directISRCs == nil {
directISRCs = make(map[string]string)
}
directISRCs[trackIDStr] = track.ISRC
}
continue
}
if isrc, ok := c.isrcCache[trackIDStr]; ok {
result[trackIDStr] = isrc
} else {
@@ -576,6 +586,13 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
}
}
c.cacheMu.RUnlock()
if len(directISRCs) > 0 {
c.cacheMu.Lock()
for trackIDStr, isrc := range directISRCs {
c.isrcCache[trackIDStr] = isrc
}
c.cacheMu.Unlock()
}
if len(tracksToFetch) == 0 {
return result
@@ -590,7 +607,6 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
go func(t deezerTrack) {
defer wg.Done()
// Acquire semaphore
select {
case sem <- struct{}{}:
defer func() { <-sem }()
@@ -619,10 +635,8 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
return result
}
// GetTrackISRC fetches ISRC for a single track (with caching)
// Use this when you need ISRC for download
func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string, error) {
// Check cache first
c.cacheMu.RLock()
if isrc, ok := c.isrcCache[trackID]; ok {
c.cacheMu.RUnlock()
@@ -630,13 +644,11 @@ func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string
}
c.cacheMu.RUnlock()
// Fetch from API
fullTrack, err := c.fetchFullTrack(ctx, trackID)
if err != nil {
return "", err
}
// Cache the result
c.cacheMu.Lock()
c.isrcCache[trackID] = fullTrack.ISRC
c.cacheMu.Unlock()
@@ -683,6 +695,104 @@ func (c *DeezerClient) getBestAlbumImage(album deezerAlbumFull) string {
return album.Cover
}
type AlbumExtendedMetadata struct {
Genre string // Comma-separated list of genres
Label string // Record label name
}
// Uses the album ID from a track to fetch extended metadata
func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID string) (*AlbumExtendedMetadata, error) {
if albumID == "" {
return nil, fmt.Errorf("empty album ID")
}
cacheKey := fmt.Sprintf("album_meta:%s", albumID)
c.cacheMu.RLock()
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
c.cacheMu.RUnlock()
return entry.data.(*AlbumExtendedMetadata), nil
}
c.cacheMu.RUnlock()
albumURL := fmt.Sprintf(deezerAlbumURL, albumID)
var album deezerAlbumFull
if err := c.getJSON(ctx, albumURL, &album); err != nil {
return nil, fmt.Errorf("failed to fetch album: %w", err)
}
var genres []string
for _, g := range album.Genres.Data {
if g.Name != "" {
genres = append(genres, g.Name)
}
}
result := &AlbumExtendedMetadata{
Genre: strings.Join(genres, ", "),
Label: album.Label,
}
c.cacheMu.Lock()
c.searchCache[cacheKey] = &cacheEntry{
data: result,
expiresAt: time.Now().Add(deezerCacheTTL),
}
c.cacheMu.Unlock()
GoLog("[Deezer] Album metadata fetched - Genre: %s, Label: %s\n", result.Genre, result.Label)
return result, nil
}
// GetTrackAlbumID fetches the album ID for a Deezer track
func (c *DeezerClient) GetTrackAlbumID(ctx context.Context, trackID string) (string, error) {
trackURL := fmt.Sprintf(deezerTrackURL, trackID)
var track deezerTrack
if err := c.getJSON(ctx, trackURL, &track); err != nil {
return "", err
}
return fmt.Sprintf("%d", track.Album.ID), nil
}
// This is a convenience function that first gets the album ID, then fetches album metadata
func (c *DeezerClient) GetExtendedMetadataByTrackID(ctx context.Context, trackID string) (*AlbumExtendedMetadata, error) {
albumID, err := c.GetTrackAlbumID(ctx, trackID)
if err != nil {
return nil, fmt.Errorf("failed to get album ID: %w", err)
}
return c.GetAlbumExtendedMetadata(ctx, albumID)
}
// GetExtendedMetadataByISRC searches for a track by ISRC and fetches extended metadata (genre, label)
func (c *DeezerClient) GetExtendedMetadataByISRC(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) {
if isrc == "" {
return nil, fmt.Errorf("empty ISRC")
}
// First, search for track by ISRC
track, err := c.SearchByISRC(ctx, isrc)
if err != nil {
return nil, fmt.Errorf("failed to find track by ISRC: %w", err)
}
// SpotifyID contains "deezer:123" format, extract the ID
deezerID := track.SpotifyID
if strings.HasPrefix(deezerID, "deezer:") {
deezerID = strings.TrimPrefix(deezerID, "deezer:")
}
if deezerID == "" {
return nil, fmt.Errorf("track found but no Deezer ID")
}
// Then fetch extended metadata using the Deezer track ID
return c.GetExtendedMetadataByTrackID(ctx, deezerID)
}
func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interface{}) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
@@ -727,7 +837,6 @@ func parseDeezerURL(input string) (string, string, error) {
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
// Skip language prefix if present (e.g., /en/, /fr/)
if len(parts) > 0 && len(parts[0]) == 2 {
parts = parts[1:]
}
+20 -20
View File
@@ -18,30 +18,45 @@ type ISRCIndex struct {
mu sync.RWMutex
}
// Global ISRC index cache (per output directory)
var (
isrcIndexCache = make(map[string]*ISRCIndex)
isrcIndexCacheMu sync.RWMutex
isrcIndexTTL = 5 * time.Minute // Cache TTL - rebuild after 5 minutes
isrcBuildingMu sync.Map // Per-directory build lock to prevent concurrent builds
isrcIndexTTL = 5 * time.Minute
)
// GetISRCIndex returns or builds an ISRC index for the given directory
// Uses per-directory mutex to prevent concurrent builds (race condition fix)
func GetISRCIndex(outputDir string) *ISRCIndex {
// Fast path: check cache first
isrcIndexCacheMu.RLock()
idx, exists := isrcIndexCache[outputDir]
isrcIndexCacheMu.RUnlock()
// Return cached index if still valid
if exists && time.Since(idx.buildTime) < isrcIndexTTL {
return idx
}
// Build new index
// 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)
mu.Lock()
defer mu.Unlock()
// Double-check cache after acquiring lock (another goroutine may have built it)
isrcIndexCacheMu.RLock()
idx, exists = isrcIndexCache[outputDir]
isrcIndexCacheMu.RUnlock()
if exists && time.Since(idx.buildTime) < isrcIndexTTL {
return idx
}
return buildISRCIndex(outputDir)
}
// buildISRCIndex scans a directory and builds a map of ISRC -> file path
// Same implementation as PC version for consistency
func buildISRCIndex(outputDir string) *ISRCIndex {
idx := &ISRCIndex{
index: make(map[string]string),
@@ -56,7 +71,6 @@ func buildISRCIndex(outputDir string) *ISRCIndex {
startTime := time.Now()
fileCount := 0
// Walk directory - only check .flac files
filepath.Walk(outputDir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
@@ -67,13 +81,11 @@ func buildISRCIndex(outputDir string) *ISRCIndex {
return nil
}
// Read ISRC from file
metadata, err := ReadMetadata(path)
if err != nil || metadata.ISRC == "" {
return nil
}
// Store in index (uppercase for case-insensitive matching)
idx.index[strings.ToUpper(metadata.ISRC)] = path
fileCount++
return nil
@@ -82,7 +94,6 @@ func buildISRCIndex(outputDir string) *ISRCIndex {
fmt.Printf("[ISRCIndex] Built index for %s: %d files in %v\n",
outputDir, fileCount, time.Since(startTime).Round(time.Millisecond))
// Cache the index
isrcIndexCacheMu.Lock()
isrcIndexCache[outputDir] = idx
isrcIndexCacheMu.Unlock()
@@ -90,7 +101,6 @@ func buildISRCIndex(outputDir string) *ISRCIndex {
return idx
}
// lookup checks if an ISRC exists in the index (internal, returns bool)
func (idx *ISRCIndex) lookup(isrc string) (string, bool) {
if isrc == "" {
return "", false
@@ -148,7 +158,6 @@ func checkISRCExistsInternal(outputDir, isrc string) (string, bool) {
return "", false
}
// Use index for fast lookup
idx := GetISRCIndex(outputDir)
filePath, exists := idx.lookup(isrc)
if !exists {
@@ -165,7 +174,6 @@ func checkISRCExistsInternal(outputDir, isrc string) (string, bool) {
}
// CheckISRCExists is the exported version for gomobile (returns string, error)
// Returns the filepath if exists, empty string if not
func CheckISRCExists(outputDir, isrc string) (string, error) {
filepath, _ := checkISRCExistsInternal(outputDir, isrc)
return filepath, nil
@@ -189,11 +197,7 @@ type FileExistenceResult struct {
ArtistName string `json:"artist_name,omitempty"`
}
// CheckFilesExistParallel checks if multiple files exist in parallel
// It builds an ISRC index from the output directory once, then checks all tracks against it
// Same implementation as PC version for consistency
func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error) {
// Parse input JSON
var tracks []struct {
ISRC string `json:"isrc"`
TrackName string `json:"track_name"`
@@ -205,10 +209,8 @@ func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error
results := make([]FileExistenceResult, len(tracks))
// Build ISRC index from output directory (scan once)
isrcIdx := GetISRCIndex(outputDir)
// Check each track against the index (parallel)
var wg sync.WaitGroup
for i, track := range tracks {
wg.Add(1)
@@ -239,7 +241,6 @@ func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error
wg.Wait()
// Return results as JSON
resultJSON, err := json.Marshal(results)
if err != nil {
return "", fmt.Errorf("failed to marshal results: %w", err)
@@ -260,7 +261,6 @@ func PreBuildISRCIndex(outputDir string) error {
}
// AddToISRCIndex adds a new file to the ISRC index after successful download
// This avoids rebuilding the entire index
func AddToISRCIndex(outputDir, isrc, filePath string) {
if outputDir == "" || isrc == "" || filePath == "" {
return
+137 -166
View File
@@ -13,8 +13,6 @@ import (
"github.com/dop251/goja"
)
// ParseSpotifyURL parses and validates a Spotify URL
// Returns JSON with type (track/album/playlist) and ID
func ParseSpotifyURL(url string) (string, error) {
parsed, err := parseSpotifyURI(url)
if err != nil {
@@ -34,19 +32,14 @@ func ParseSpotifyURL(url string) (string, error) {
return string(jsonBytes), nil
}
// SetSpotifyAPICredentials sets custom Spotify API credentials from Flutter
func SetSpotifyAPICredentials(clientID, clientSecret string) {
SetSpotifyCredentials(clientID, clientSecret)
}
// CheckSpotifyCredentials checks if Spotify credentials are configured
// Returns true if credentials are available (custom or env vars)
func CheckSpotifyCredentials() bool {
return HasSpotifyCredentials()
}
// GetSpotifyMetadata fetches metadata from Spotify URL
// Returns JSON with track/album/playlist data
func GetSpotifyMetadata(spotifyURL string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
@@ -68,8 +61,6 @@ func GetSpotifyMetadata(spotifyURL string) (string, error) {
return string(jsonBytes), nil
}
// SearchSpotify searches for tracks on Spotify
// Returns JSON array of track results
func SearchSpotify(query string, limit int) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
@@ -91,8 +82,6 @@ func SearchSpotify(query string, limit int) (string, error) {
return string(jsonBytes), nil
}
// SearchSpotifyAll searches for tracks and artists on Spotify
// Returns JSON with tracks and artists arrays
func SearchSpotifyAll(query string, trackLimit, artistLimit int) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
@@ -114,8 +103,6 @@ func SearchSpotifyAll(query string, trackLimit, artistLimit int) (string, error)
return string(jsonBytes), nil
}
// CheckAvailability checks track availability on streaming services
// Returns JSON with availability info for Tidal, Qobuz, Amazon
func CheckAvailability(spotifyID, isrc string) (string, error) {
client := NewSongLinkClient()
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
@@ -131,7 +118,6 @@ func CheckAvailability(spotifyID, isrc string) (string, error) {
return string(jsonBytes), nil
}
// DownloadRequest represents a download request from Flutter
type DownloadRequest struct {
ISRC string `json:"isrc"`
Service string `json:"service"`
@@ -143,49 +129,51 @@ type DownloadRequest struct {
CoverURL string `json:"cover_url"`
OutputDir string `json:"output_dir"`
FilenameFormat string `json:"filename_format"`
Quality string `json:"quality"` // LOSSLESS, HI_RES, HI_RES_LOSSLESS
Quality string `json:"quality"`
EmbedLyrics bool `json:"embed_lyrics"`
EmbedMaxQualityCover bool `json:"embed_max_quality_cover"`
TrackNumber int `json:"track_number"`
DiscNumber int `json:"disc_number"`
TotalTracks int `json:"total_tracks"`
ReleaseDate string `json:"release_date"`
ItemID string `json:"item_id"` // Unique ID for progress tracking
DurationMS int `json:"duration_ms"` // Expected duration in milliseconds (for verification)
Source string `json:"source"` // Extension ID that provided this track (prioritize this extension)
// Enriched IDs from Odesli/song.link - used to skip search and directly fetch
TidalID string `json:"tidal_id,omitempty"`
QobuzID string `json:"qobuz_id,omitempty"`
DeezerID string `json:"deezer_id,omitempty"`
ItemID string `json:"item_id"`
DurationMS int `json:"duration_ms"`
Source string `json:"source"`
Genre string `json:"genre,omitempty"`
Label string `json:"label,omitempty"`
Copyright string `json:"copyright,omitempty"`
TidalID string `json:"tidal_id,omitempty"`
QobuzID string `json:"qobuz_id,omitempty"`
DeezerID string `json:"deezer_id,omitempty"`
LyricsMode string `json:"lyrics_mode,omitempty"`
}
// DownloadResponse represents the result of a download
type DownloadResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
FilePath string `json:"file_path,omitempty"`
Error string `json:"error,omitempty"`
ErrorType string `json:"error_type,omitempty"` // "not_found", "rate_limit", "network", "unknown"
AlreadyExists bool `json:"already_exists,omitempty"`
// Actual quality info from the source
ActualBitDepth int `json:"actual_bit_depth,omitempty"`
ActualSampleRate int `json:"actual_sample_rate,omitempty"`
Service string `json:"service,omitempty"` // Actual service used (for fallback)
Title string `json:"title,omitempty"`
Artist string `json:"artist,omitempty"`
Album string `json:"album,omitempty"`
AlbumArtist string `json:"album_artist,omitempty"`
ReleaseDate string `json:"release_date,omitempty"`
TrackNumber int `json:"track_number,omitempty"`
DiscNumber int `json:"disc_number,omitempty"`
ISRC string `json:"isrc,omitempty"`
CoverURL string `json:"cover_url,omitempty"`
// If true, skip metadata enrichment from Deezer/Spotify (extension already provides metadata)
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"`
Success bool `json:"success"`
Message string `json:"message"`
FilePath string `json:"file_path,omitempty"`
Error string `json:"error,omitempty"`
ErrorType string `json:"error_type,omitempty"` // "not_found", "rate_limit", "network", "unknown"
AlreadyExists bool `json:"already_exists,omitempty"`
ActualBitDepth int `json:"actual_bit_depth,omitempty"`
ActualSampleRate int `json:"actual_sample_rate,omitempty"`
Service string `json:"service,omitempty"` // Actual service used (for fallback)
Title string `json:"title,omitempty"`
Artist string `json:"artist,omitempty"`
Album string `json:"album,omitempty"`
AlbumArtist string `json:"album_artist,omitempty"`
ReleaseDate string `json:"release_date,omitempty"`
TrackNumber int `json:"track_number,omitempty"`
DiscNumber int `json:"disc_number,omitempty"`
ISRC string `json:"isrc,omitempty"`
CoverURL string `json:"cover_url,omitempty"`
Genre string `json:"genre,omitempty"`
Label string `json:"label,omitempty"`
Copyright string `json:"copyright,omitempty"`
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"`
}
// DownloadResult is a generic result type for all downloaders
// DownloadResult is a generic result type for all downloaders
type DownloadResult struct {
FilePath string
BitDepth int
@@ -199,9 +187,6 @@ type DownloadResult struct {
ISRC string
}
// DownloadTrack downloads a track from the specified service
// requestJSON is a JSON string of DownloadRequest
// Returns JSON string of DownloadResponse
func DownloadTrack(requestJSON string) (string, error) {
var req DownloadRequest
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
@@ -215,7 +200,6 @@ func DownloadTrack(requestJSON string) (string, error) {
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
req.OutputDir = strings.TrimSpace(req.OutputDir)
// Add output directory to allowed download dirs for extensions
if req.OutputDir != "" {
AddAllowedDownloadDir(req.OutputDir)
}
@@ -283,10 +267,8 @@ func DownloadTrack(requestJSON string) (string, error) {
return errorResponse(err.Error())
}
// Check if file already exists
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
actualPath := result.FilePath[7:]
// Read actual quality from existing file
quality, qErr := GetAudioQuality(actualPath)
if qErr == nil {
result.BitDepth = quality.BitDepth
@@ -312,7 +294,6 @@ func DownloadTrack(requestJSON string) (string, error) {
return string(jsonBytes), nil
}
// Read actual quality from downloaded file (more accurate than API)
quality, qErr := GetAudioQuality(result.FilePath)
if qErr == nil {
result.BitDepth = quality.BitDepth
@@ -342,27 +323,22 @@ func DownloadTrack(requestJSON string) (string, error) {
return string(jsonBytes), nil
}
// DownloadWithFallback tries to download from services in order
// Starts with the preferred service from request, then tries others
func DownloadWithFallback(requestJSON string) (string, error) {
var req DownloadRequest
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
return errorResponse("Invalid request: " + err.Error())
}
// Trim whitespace from string fields to prevent filename/path issues
req.TrackName = strings.TrimSpace(req.TrackName)
req.ArtistName = strings.TrimSpace(req.ArtistName)
req.AlbumName = strings.TrimSpace(req.AlbumName)
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
req.OutputDir = strings.TrimSpace(req.OutputDir)
// Add output directory to allowed download dirs for extensions
if req.OutputDir != "" {
AddAllowedDownloadDir(req.OutputDir)
}
// Build service order starting with preferred service
allServices := []string{"tidal", "qobuz", "amazon"}
preferredService := req.Service
if preferredService == "" {
@@ -371,7 +347,6 @@ func DownloadWithFallback(requestJSON string) (string, error) {
GoLog("[DownloadWithFallback] Preferred service from request: '%s'\n", req.Service)
// Create ordered list: preferred first, then others
services := []string{preferredService}
for _, s := range allServices {
if s != preferredService {
@@ -455,10 +430,8 @@ func DownloadWithFallback(requestJSON string) (string, error) {
}
if err == nil {
// Check if file already exists
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
actualPath := result.FilePath[7:]
// Read actual quality from existing file
quality, qErr := GetAudioQuality(actualPath)
if qErr == nil {
result.BitDepth = quality.BitDepth
@@ -484,7 +457,6 @@ func DownloadWithFallback(requestJSON string) (string, error) {
return string(jsonBytes), nil
}
// Read actual quality from downloaded file (more accurate than API)
quality, qErr := GetAudioQuality(result.FilePath)
if qErr == nil {
result.BitDepth = quality.BitDepth
@@ -519,58 +491,44 @@ func DownloadWithFallback(requestJSON string) (string, error) {
return errorResponse("All services failed. Last error: " + lastErr.Error())
}
// GetDownloadProgress returns current download progress
func GetDownloadProgress() string {
progress := getProgress()
jsonBytes, _ := json.Marshal(progress)
return string(jsonBytes)
}
// GetAllDownloadProgress returns progress for all active downloads (concurrent mode)
func GetAllDownloadProgress() string {
return GetMultiProgress()
}
// InitItemProgress initializes progress tracking for a download item
func InitItemProgress(itemID string) {
StartItemProgress(itemID)
}
// FinishItemProgress marks a download item as complete and removes tracking
func FinishItemProgress(itemID string) {
CompleteItemProgress(itemID)
// Don't remove immediately - let Flutter poll one more time to see 100%
}
// ClearItemProgress removes progress tracking for a specific item
func ClearItemProgress(itemID string) {
RemoveItemProgress(itemID)
}
// CancelDownload cancels an in-progress download for the given item.
func CancelDownload(itemID string) {
cancelDownload(itemID)
}
// CleanupConnections closes idle HTTP connections
// Call this periodically during large batch downloads to prevent TCP exhaustion
func CleanupConnections() {
CloseIdleConnections()
}
// ReadFileMetadata reads metadata directly from a FLAC file
// Returns JSON with all embedded metadata (title, artist, album, track number, etc.)
// This is useful for displaying accurate metadata in the UI without relying on cached data
func ReadFileMetadata(filePath string) (string, error) {
metadata, err := ReadMetadata(filePath)
if err != nil {
return "", fmt.Errorf("failed to read metadata: %w", err)
}
// Also get audio quality info
quality, qualityErr := GetAudioQuality(filePath)
// Get duration from FLAC stream info
duration := 0
if qualityErr == nil && quality.SampleRate > 0 && quality.TotalSamples > 0 {
duration = int(quality.TotalSamples / int64(quality.SampleRate))
@@ -589,7 +547,6 @@ func ReadFileMetadata(filePath string) (string, error) {
"duration": duration,
}
// Add quality info if available
if qualityErr == nil {
result["bit_depth"] = quality.BitDepth
result["sample_rate"] = quality.SampleRate
@@ -603,12 +560,10 @@ func ReadFileMetadata(filePath string) (string, error) {
return string(jsonBytes), nil
}
// SetDownloadDirectory sets the default download directory
func SetDownloadDirectory(path string) error {
return setDownloadDir(path)
}
// CheckDuplicate checks if a file with the given ISRC exists
func CheckDuplicate(outputDir, isrc string) (string, error) {
existingFile, exists := CheckISRCExists(outputDir, isrc)
@@ -625,27 +580,18 @@ func CheckDuplicate(outputDir, isrc string) (string, error) {
return string(jsonBytes), nil
}
// CheckDuplicatesBatch checks multiple files for duplicates in parallel
// Uses ISRC index for fast lookup (builds index once, checks all tracks)
// tracksJSON format: [{"isrc": "...", "track_name": "...", "artist_name": "..."}, ...]
// Returns JSON array of results
func CheckDuplicatesBatch(outputDir, tracksJSON string) (string, error) {
return CheckFilesExistParallel(outputDir, tracksJSON)
}
// PreBuildDuplicateIndex pre-builds the ISRC index for a directory
// Call this when entering album/playlist screen for faster duplicate checking
func PreBuildDuplicateIndex(outputDir string) error {
return PreBuildISRCIndex(outputDir)
}
// InvalidateDuplicateIndex clears the ISRC index cache for a directory
// Call this when files are deleted or moved
func InvalidateDuplicateIndex(outputDir string) {
InvalidateISRCCache(outputDir)
}
// BuildFilename builds a filename from template and metadata
func BuildFilename(template string, metadataJSON string) (string, error) {
var metadata map[string]interface{}
if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
@@ -656,16 +602,14 @@ func BuildFilename(template string, metadataJSON string) (string, error) {
return filename, nil
}
// SanitizeFilename removes invalid characters from filename
func SanitizeFilename(filename string) string {
return sanitizeFilename(filename)
}
// FetchLyrics fetches lyrics for a track from LRCLIB
// Returns JSON with lyrics data
func FetchLyrics(spotifyID, trackName, artistName string) (string, error) {
func FetchLyrics(spotifyID, trackName, artistName string, durationMs int64) (string, error) {
client := NewLyricsClient()
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName)
durationSec := float64(durationMs) / 1000.0
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
if err != nil {
return "", err
}
@@ -685,10 +629,7 @@ func FetchLyrics(spotifyID, trackName, artistName string) (string, error) {
return string(jsonBytes), nil
}
// GetLyricsLRC fetches lyrics and converts to LRC format string with metadata headers
// First tries to extract from file, then falls back to fetching from internet
func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string) (string, error) {
// Try to extract from file first (much faster)
func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, durationMs int64) (string, error) {
if filePath != "" {
lyrics, err := ExtractLyrics(filePath)
if err == nil && lyrics != "" {
@@ -696,19 +637,17 @@ func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string) (str
}
}
// Fallback to fetching from internet
client := NewLyricsClient()
lyricsData, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName)
durationSec := float64(durationMs) / 1000.0
lyricsData, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
if err != nil {
return "", err
}
// Convert to LRC format with metadata headers (like PC version)
lrcContent := convertToLRCWithMetadata(lyricsData, trackName, artistName)
return lrcContent, nil
}
// EmbedLyricsToFile embeds lyrics into an existing FLAC file
func EmbedLyricsToFile(filePath, lyrics string) (string, error) {
err := EmbedLyrics(filePath, lyrics)
if err != nil {
@@ -724,9 +663,6 @@ func EmbedLyricsToFile(filePath, lyrics string) (string, error) {
return string(jsonBytes), nil
}
// PreWarmTrackCacheJSON pre-warms the track ID cache for album/playlist tracks
// tracksJSON is a JSON array of objects with: isrc, track_name, artist_name, spotify_id, service
// This runs in background and returns immediately
func PreWarmTrackCacheJSON(tracksJSON string) (string, error) {
var tracks []struct {
ISRC string `json:"isrc"`
@@ -740,7 +676,6 @@ func PreWarmTrackCacheJSON(tracksJSON string) (string, error) {
return errorResponse("Invalid JSON: " + err.Error())
}
// Convert to PreWarmCacheRequest
requests := make([]PreWarmCacheRequest, len(tracks))
for i, t := range tracks {
requests[i] = PreWarmCacheRequest{
@@ -752,7 +687,6 @@ func PreWarmTrackCacheJSON(tracksJSON string) (string, error) {
}
}
// Run in background
go PreWarmTrackCache(requests)
resp := map[string]interface{}{
@@ -764,20 +698,14 @@ func PreWarmTrackCacheJSON(tracksJSON string) (string, error) {
return string(jsonBytes), nil
}
// GetTrackCacheSize returns the current track ID cache size
func GetTrackCacheSize() int {
return GetCacheSize()
}
// ClearTrackIDCache clears the track ID cache
func ClearTrackIDCache() {
ClearTrackCache()
}
// ==================== DEEZER API ====================
// SearchDeezerAll searches for tracks and artists on Deezer (no API key required)
// Returns JSON with tracks and artists arrays
func SearchDeezerAll(query string, trackLimit, artistLimit int) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
@@ -852,6 +780,37 @@ func ParseDeezerURLExport(url string) (string, error) {
return string(jsonBytes), nil
}
// GetDeezerExtendedMetadata fetches genre and label from Deezer album
// trackID: Deezer track ID (will look up album ID from track)
// Returns JSON with genre, label fields
func GetDeezerExtendedMetadata(trackID string) (string, error) {
if trackID == "" {
return "", fmt.Errorf("empty track ID")
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
client := GetDeezerClient()
metadata, err := client.GetExtendedMetadataByTrackID(ctx, trackID)
if err != nil {
GoLog("[Deezer] Failed to get extended metadata: %v\n", err)
return "", err
}
result := map[string]string{
"genre": metadata.Genre,
"label": metadata.Label,
}
jsonBytes, err := json.Marshal(result)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
// SearchDeezerByISRC searches for a track by ISRC on Deezer
func SearchDeezerByISRC(isrc string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
@@ -872,7 +831,6 @@ func SearchDeezerByISRC(isrc string) (string, error) {
}
// ConvertSpotifyToDeezer converts a Spotify track/album ID to Deezer and fetches metadata
// This uses SongLink API to find the Deezer equivalent, then fetches from Deezer
// Useful when Spotify API is rate limited
func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
@@ -881,14 +839,12 @@ func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
songlink := NewSongLinkClient()
deezerClient := GetDeezerClient()
// For tracks, we can use SongLink to get Deezer ID
if resourceType == "track" {
deezerID, err := songlink.GetDeezerIDFromSpotify(spotifyID)
if err != nil {
return "", fmt.Errorf("could not find Deezer equivalent: %w", err)
}
// Fetch metadata from Deezer
trackResp, err := deezerClient.GetTrack(ctx, deezerID)
if err != nil {
return "", fmt.Errorf("failed to fetch Deezer metadata: %w", err)
@@ -902,14 +858,12 @@ func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
return string(jsonBytes), nil
}
// For albums, SongLink also provides mapping
if resourceType == "album" {
deezerID, err := songlink.GetDeezerAlbumIDFromSpotify(spotifyID)
if err != nil {
return "", fmt.Errorf("could not find Deezer album: %w", err)
}
// Fetch album metadata from Deezer
albumResp, err := deezerClient.GetAlbum(ctx, deezerID)
if err != nil {
return "", fmt.Errorf("failed to fetch Deezer album metadata: %w", err)
@@ -932,10 +886,8 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Try Spotify first
client, err := NewSpotifyMetadataClient()
if err != nil {
// No Spotify credentials - fall through to Deezer fallback
LogWarn("Spotify", "Credentials not configured, falling back to Deezer")
} else {
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
@@ -947,15 +899,12 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
return string(jsonBytes), nil
}
// Check if it's a rate limit error
errStr := strings.ToLower(err.Error())
if !strings.Contains(errStr, "429") && !strings.Contains(errStr, "rate") && !strings.Contains(errStr, "limit") {
// Not a rate limit error, return original error
return "", err
}
}
// Rate limited - try Deezer fallback for tracks and albums
parsed, parseErr := parseSpotifyURI(spotifyURL)
if parseErr != nil {
return "", fmt.Errorf("spotify rate limited and failed to parse URL: %w", parseErr)
@@ -964,11 +913,9 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
GoLog("[Fallback] Spotify rate limited for %s, trying Deezer...\n", parsed.Type)
if parsed.Type == "track" || parsed.Type == "album" {
// Convert to Deezer
return ConvertSpotifyToDeezer(parsed.Type, parsed.ID)
}
// Artist and playlist not supported for fallback
if parsed.Type == "artist" {
return "", fmt.Errorf("spotify rate limited. Artist pages require Spotify API - please try again later")
}
@@ -976,10 +923,6 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
return "", fmt.Errorf("spotify rate limited. Playlists are user-specific and require Spotify API")
}
// ==================== SONGLINK DEEZER SUPPORT ====================
// CheckAvailabilityFromDeezerID checks track availability using Deezer track ID as source
// Returns JSON with availability info for Spotify, Tidal, Amazon, etc.
func CheckAvailabilityFromDeezerID(deezerTrackID string) (string, error) {
client := NewSongLinkClient()
availability, err := client.CheckAvailabilityFromDeezer(deezerTrackID)
@@ -1033,7 +976,6 @@ func GetAmazonURLFromDeezerTrack(deezerTrackID string) (string, error) {
}
func errorResponse(msg string) (string, error) {
// Determine error type based on message
errorType := "unknown"
lowerMsg := strings.ToLower(msg)
@@ -1122,7 +1064,6 @@ func LoadExtensionFromPath(filePath string) (string, error) {
return "", err
}
// Initialize with saved settings
settingsStore := GetExtensionSettingsStore()
settings := settingsStore.GetAll(ext.ID)
if len(settings) > 0 {
@@ -1165,14 +1106,12 @@ func UpgradeExtensionFromPath(filePath string) (string, error) {
return "", err
}
// Initialize with saved settings
settingsStore := GetExtensionSettingsStore()
settings := settingsStore.GetAll(ext.ID)
if len(settings) > 0 {
manager.InitializeExtension(ext.ID, settings)
}
// Return extension info as JSON
result := map[string]interface{}{
"id": ext.ID,
"display_name": ext.Manifest.DisplayName,
@@ -1273,7 +1212,6 @@ func SetExtensionSettingsJSON(extensionID, settingsJSON string) error {
return err
}
// Re-initialize extension with new settings
manager := GetExtensionManager()
return manager.InitializeExtension(extensionID, settings)
}
@@ -1320,7 +1258,22 @@ func CleanupExtensions() {
manager.UnloadAllExtensions()
}
// ==================== EXTENSION AUTH API ====================
// InvokeExtensionActionJSON invokes a custom action on an extension (e.g., button click handler)
// actionName is the JS function name to call (e.g., "startLogin", "authenticate", etc.)
func InvokeExtensionActionJSON(extensionID, actionName string) (string, error) {
manager := GetExtensionManager()
result, err := manager.InvokeAction(extensionID, actionName)
if err != nil {
return "", err
}
jsonBytes, err := json.Marshal(result)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
// GetExtensionPendingAuthJSON returns pending auth request for an extension
func GetExtensionPendingAuthJSON(extensionID string) (string, error) {
@@ -1372,7 +1325,6 @@ func IsExtensionAuthenticatedByID(extensionID string) bool {
return false
}
// Check if token is expired
if state.IsAuthenticated && !state.ExpiresAt.IsZero() && time.Now().After(state.ExpiresAt) {
return false
}
@@ -1402,9 +1354,6 @@ func GetAllPendingAuthRequestsJSON() (string, error) {
return string(jsonBytes), nil
}
// ==================== EXTENSION FFMPEG API ====================
// GetPendingFFmpegCommandJSON returns a pending FFmpeg command for Flutter to execute
func GetPendingFFmpegCommandJSON(commandID string) (string, error) {
cmd := GetPendingFFmpegCommand(commandID)
if cmd == nil {
@@ -1464,12 +1413,10 @@ func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error)
manager := GetExtensionManager()
ext, err := manager.GetExtension(extensionID)
if err != nil {
// Extension not found, return original track
return trackJSON, nil
}
if !ext.Manifest.IsMetadataProvider() {
// Not a metadata provider, return original
return trackJSON, nil
}
@@ -1481,7 +1428,6 @@ func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error)
provider := NewExtensionProviderWrapper(ext)
enrichedTrack, err := provider.EnrichTrack(&track)
if err != nil {
// Error enriching, return original
return trackJSON, nil
}
@@ -1518,7 +1464,6 @@ func CustomSearchWithExtensionJSON(extensionID, query string, optionsJSON string
return "", err
}
// Convert to map format for Flutter, ensuring images field is set
result := make([]map[string]interface{}, len(tracks))
for i, track := range tracks {
result[i] = map[string]interface{}{
@@ -1571,10 +1516,6 @@ func GetSearchProvidersJSON() (string, error) {
return string(jsonBytes), nil
}
// ==================== EXTENSION URL HANDLER ====================
// HandleURLWithExtensionJSON tries to handle a URL with any matching extension
// Returns JSON with type, tracks, album info, etc.
func HandleURLWithExtensionJSON(url string) (string, error) {
manager := GetExtensionManager()
resultWithID, err := manager.HandleURLWithExtension(url)
@@ -1585,12 +1526,10 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
result := resultWithID.Result
extensionID := resultWithID.ExtensionID
// Check if result is nil (handler found but returned error)
if result == nil {
return "", fmt.Errorf("extension %s failed to handle URL", extensionID)
}
// Build response
response := map[string]interface{}{
"type": result.Type,
"extension_id": extensionID,
@@ -1598,7 +1537,6 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
"cover_url": result.CoverURL,
}
// Add track if single track
if result.Track != nil {
response["track"] = map[string]interface{}{
"id": result.Track.ID,
@@ -1616,7 +1554,6 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
}
}
// Add tracks if multiple
if len(result.Tracks) > 0 {
tracks := make([]map[string]interface{}, len(result.Tracks))
for i, track := range result.Tracks {
@@ -1654,7 +1591,6 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
}
}
// Add artist info if present
if result.Artist != nil {
artistResponse := map[string]interface{}{
"id": result.Artist.ID,
@@ -1665,7 +1601,6 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
"provider_id": result.Artist.ProviderID,
}
// Add albums if present
if len(result.Artist.Albums) > 0 {
albums := make([]map[string]interface{}, len(result.Artist.Albums))
for i, album := range result.Artist.Albums {
@@ -1688,7 +1623,6 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
artistResponse["albums"] = albums
}
// Add top tracks if present
if len(result.Artist.TopTracks) > 0 {
topTracks := make([]map[string]interface{}, len(result.Artist.TopTracks))
for i, track := range result.Artist.TopTracks {
@@ -1758,10 +1692,8 @@ func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) {
return "", fmt.Errorf("album not found")
}
// Convert tracks to map format
tracks := make([]map[string]interface{}, len(album.Tracks))
for i, track := range album.Tracks {
// Use album cover as fallback if track doesn't have its own cover
trackCover := track.ResolvedCoverURL()
if trackCover == "" {
trackCover = album.CoverURL
@@ -1788,6 +1720,7 @@ func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) {
"id": album.ID,
"name": album.Name,
"artists": album.Artists,
"artist_id": album.ArtistID,
"cover_url": album.CoverURL,
"release_date": album.ReleaseDate,
"total_tracks": album.TotalTracks,
@@ -1818,7 +1751,6 @@ func GetPlaylistWithExtensionJSON(extensionID, playlistID string) (string, error
provider := NewExtensionProviderWrapper(ext)
// Try getPlaylist first, fall back to getAlbum (some extensions use album for playlists)
script := fmt.Sprintf(`
(function() {
if (typeof extension !== 'undefined' && typeof extension.getPlaylist === 'function') {
@@ -1846,7 +1778,6 @@ func GetPlaylistWithExtensionJSON(extensionID, playlistID string) (string, error
return "", fmt.Errorf("failed to marshal result: %w", err)
}
// Parse into album metadata (same structure)
var album ExtAlbumMetadata
if err := json.Unmarshal(jsonBytes, &album); err != nil {
return "", fmt.Errorf("failed to parse playlist: %w", err)
@@ -1856,10 +1787,8 @@ func GetPlaylistWithExtensionJSON(extensionID, playlistID string) (string, error
album.Tracks[i].ProviderID = ext.ID
}
// Convert tracks to map format
tracks := make([]map[string]interface{}, len(album.Tracks))
for i, track := range album.Tracks {
// Use playlist cover as fallback if track doesn't have its own cover
trackCover := track.ResolvedCoverURL()
if trackCover == "" {
trackCover = album.CoverURL
@@ -1922,7 +1851,6 @@ func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) {
return "", fmt.Errorf("artist not found")
}
// Convert albums to map format
albums := make([]map[string]interface{}, len(artist.Albums))
for i, album := range artist.Albums {
albums[i] = map[string]interface{}{
@@ -1950,7 +1878,6 @@ func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) {
response["header_image"] = artist.HeaderImage
}
// Add listeners if present
if artist.Listeners > 0 {
response["listeners"] = artist.Listeners
}
@@ -2008,9 +1935,6 @@ func GetURLHandlersJSON() (string, error) {
return string(jsonBytes), nil
}
// ==================== EXTENSION POST-PROCESSING ====================
// RunPostProcessingJSON runs post-processing hooks on a file
func RunPostProcessingJSON(filePath, metadataJSON string) (string, error) {
var metadata map[string]interface{}
if metadataJSON != "" {
@@ -2066,8 +1990,6 @@ func GetPostProcessingProvidersJSON() (string, error) {
return string(jsonBytes), nil
}
// ==================== EXTENSION STORE ====================
// InitExtensionStoreJSON initializes the extension store with cache directory
func InitExtensionStoreJSON(cacheDir string) error {
InitExtensionStore(cacheDir)
@@ -2081,7 +2003,6 @@ func GetStoreExtensionsJSON(forceRefresh bool) (string, error) {
return "", fmt.Errorf("extension store not initialized")
}
// Force refresh if requested
if forceRefresh {
store.FetchRegistry(true)
}
@@ -2162,3 +2083,53 @@ func ClearStoreCacheJSON() error {
store.ClearCache()
return nil
}
func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Duration) (string, error) {
manager := GetExtensionManager()
ext, err := manager.GetExtension(extensionID)
if err != nil {
return "", err
}
if !ext.Enabled {
return "", fmt.Errorf("extension '%s' is disabled", extensionID)
}
provider := NewExtensionProviderWrapper(ext)
script := fmt.Sprintf(`
(function() {
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
return extension.%s();
}
return null;
})()
`, functionName, functionName)
result, err := RunWithTimeoutAndRecover(provider.vm, script, timeout)
if err != nil {
return "", fmt.Errorf("%s failed: %w", functionName, err)
}
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
return "", fmt.Errorf("%s returned null", functionName)
}
exported := result.Export()
jsonBytes, err := json.Marshal(exported)
if err != nil {
return "", fmt.Errorf("failed to marshal result: %w", err)
}
return string(jsonBytes), nil
}
// GetExtensionHomeFeedJSON calls getHomeFeed on any extension that supports it
func GetExtensionHomeFeedJSON(extensionID string) (string, error) {
return callExtensionFunctionJSON(extensionID, "getHomeFeed", 60*time.Second)
}
// GetExtensionBrowseCategoriesJSON calls getBrowseCategories on any extension that supports it
func GetExtensionBrowseCategoriesJSON(extensionID string) (string, error) {
return callExtensionFunctionJSON(extensionID, "getBrowseCategories", 30*time.Second)
}
+85 -100
View File
@@ -1,4 +1,3 @@
// Package gobackend provides extension management functionality
package gobackend
import (
@@ -15,14 +14,10 @@ import (
"github.com/dop251/goja"
)
// compareVersions compares two semantic version strings
// Returns: -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2
func compareVersions(v1, v2 string) int {
// Parse version parts
parts1 := strings.Split(strings.TrimPrefix(v1, "v"), ".")
parts2 := strings.Split(strings.TrimPrefix(v2, "v"), ".")
// Pad shorter version with zeros
maxLen := len(parts1)
if len(parts2) > maxLen {
maxLen = len(parts2)
@@ -48,16 +43,16 @@ func compareVersions(v1, v2 string) int {
return 0
}
// LoadedExtension represents an extension that has been loaded into memory
type LoadedExtension struct {
ID string `json:"id"`
Manifest *ExtensionManifest `json:"manifest"`
VM *goja.Runtime `json:"-"` // Goja VM instance (not serialized)
VM *goja.Runtime `json:"-"`
VMMu sync.Mutex `json:"-"` // Mutex to prevent concurrent VM access
Enabled bool `json:"enabled"`
Error string `json:"error,omitempty"`
DataDir string `json:"data_dir"` // Extension's data directory
SourceDir string `json:"source_dir"` // Where extension files are extracted
IconPath string `json:"icon_path"` // Full path to icon file (if exists)
DataDir string `json:"data_dir"`
SourceDir string `json:"source_dir"`
IconPath string `json:"icon_path"`
}
// ExtensionManager manages all loaded extensions
@@ -68,13 +63,11 @@ type ExtensionManager struct {
dataDir string // Base directory for extension data
}
// Global extension manager instance
var (
globalExtManager *ExtensionManager
globalExtManagerOnce sync.Once
)
// GetExtensionManager returns the global extension manager instance
func GetExtensionManager() *ExtensionManager {
globalExtManagerOnce.Do(func() {
globalExtManager = &ExtensionManager{
@@ -84,7 +77,6 @@ func GetExtensionManager() *ExtensionManager {
return globalExtManager
}
// SetDirectories sets the extensions and data directories
func (m *ExtensionManager) SetDirectories(extensionsDir, dataDir string) error {
m.mu.Lock()
defer m.mu.Unlock()
@@ -92,7 +84,6 @@ func (m *ExtensionManager) SetDirectories(extensionsDir, dataDir string) error {
m.extensionsDir = extensionsDir
m.dataDir = dataDir
// Create directories if they don't exist
if err := os.MkdirAll(extensionsDir, 0755); err != nil {
return fmt.Errorf("failed to create extensions directory: %w", err)
}
@@ -103,9 +94,7 @@ func (m *ExtensionManager) SetDirectories(extensionsDir, dataDir string) error {
return nil
}
// LoadExtensionFromFile loads an extension from a .spotiflac-ext file
func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtension, error) {
// Validate file extension
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
}
@@ -117,7 +106,6 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
}
defer zipReader.Close()
// Find and read manifest.json
var manifestData []byte
var hasIndexJS bool
for _, file := range zipReader.File {
@@ -146,13 +134,11 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
return nil, fmt.Errorf("Invalid extension package: index.js not found")
}
// Parse and validate manifest
manifest, err := ParseManifest(manifestData)
if err != nil {
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
}
// Check if extension already loaded - if so, try upgrade (check without holding lock for long)
m.mu.RLock()
existing, exists := m.extensions[manifest.Name]
var existingVersion string
@@ -164,7 +150,6 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
m.mu.RUnlock()
if exists {
// Check if this is an upgrade
versionCompare := compareVersions(manifest.Version, existingVersion)
if versionCompare > 0 {
// This is an upgrade - call UpgradeExtension
@@ -176,29 +161,23 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
}
}
// Now acquire write lock for the rest of the operation
m.mu.Lock()
defer m.mu.Unlock()
// Double-check extension wasn't added while we were waiting for lock
if _, exists := m.extensions[manifest.Name]; exists {
return nil, fmt.Errorf("Extension '%s' was installed by another process", manifest.DisplayName)
}
// Create extension directory
extDir := filepath.Join(m.extensionsDir, manifest.Name)
if err := os.MkdirAll(extDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create extension directory: %w", err)
}
// Extract all files (preserving directory structure)
for _, file := range zipReader.File {
if file.FileInfo().IsDir() {
continue
}
// Preserve relative path within the zip (support subdirectories)
// Clean the path to prevent path traversal attacks
relPath := filepath.Clean(file.Name)
if strings.HasPrefix(relPath, "..") || filepath.IsAbs(relPath) {
GoLog("[Extension] Skipping unsafe path in archive: %s\n", file.Name)
@@ -206,19 +185,16 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
}
destPath := filepath.Join(extDir, relPath)
// Create parent directories if needed
destDir := filepath.Dir(destPath)
if err := os.MkdirAll(destDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create directory %s: %w", destDir, err)
}
// Create destination file
destFile, err := os.Create(destPath)
if err != nil {
return nil, fmt.Errorf("failed to create file %s: %w", destPath, err)
}
// Copy content
srcFile, err := file.Open()
if err != nil {
destFile.Close()
@@ -233,13 +209,11 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
}
}
// Create data directory for extension
extDataDir := filepath.Join(m.dataDir, manifest.Name)
if err := os.MkdirAll(extDataDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create extension data directory: %w", err)
}
// Create loaded extension
ext := &LoadedExtension{
ID: manifest.Name,
Manifest: manifest,
@@ -261,25 +235,20 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
return ext, nil
}
// initializeVM creates and initializes the Goja VM for an extension
func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
// Create new Goja runtime
vm := goja.New()
ext.VM = vm
// Read index.js
indexPath := filepath.Join(ext.SourceDir, "index.js")
jsCode, err := os.ReadFile(indexPath)
if err != nil {
return fmt.Errorf("failed to read index.js: %w", err)
}
// Create extension runtime and register sandboxed APIs
runtime := NewExtensionRuntime(ext)
runtime.RegisterAPIs(vm)
runtime.RegisterGoBackendAPIs(vm)
// Set up console.log for debugging
console := vm.NewObject()
console.Set("log", func(call goja.FunctionCall) goja.Value {
args := make([]interface{}, len(call.Arguments))
@@ -291,12 +260,10 @@ func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
})
vm.Set("console", console)
// Set up registerExtension function
var registeredExtension goja.Value
vm.Set("registerExtension", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) > 0 {
registeredExtension = call.Arguments[0]
// Also set it as global 'extension' variable for later access
vm.Set("extension", call.Arguments[0])
}
return goja.Undefined()
@@ -344,7 +311,6 @@ func (m *ExtensionManager) UnloadExtension(extensionID string) error {
return nil
}
// GetExtension returns a loaded extension by ID
// Returns error if extension not found (gomobile compatible)
func (m *ExtensionManager) GetExtension(extensionID string) (*LoadedExtension, error) {
m.mu.RLock()
@@ -369,7 +335,6 @@ func (m *ExtensionManager) GetAllExtensions() []*LoadedExtension {
return result
}
// SetExtensionEnabled enables or disables an extension
func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool) error {
m.mu.Lock()
defer m.mu.Unlock()
@@ -406,7 +371,6 @@ func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string
for _, entry := range entries {
if entry.IsDir() {
// Check if it's an extracted extension directory
manifestPath := filepath.Join(dirPath, entry.Name(), "manifest.json")
if _, err := os.Stat(manifestPath); err == nil {
ext, err := m.loadExtensionFromDirectory(filepath.Join(dirPath, entry.Name()))
@@ -418,7 +382,6 @@ func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string
}
}
} else if strings.HasSuffix(strings.ToLower(entry.Name()), ".spotiflac-ext") {
// Load from package file
ext, err := m.LoadExtensionFromFile(filepath.Join(dirPath, entry.Name()))
if err != nil {
GoLog("[Extension] Failed to load %s: %v\n", entry.Name(), err)
@@ -432,12 +395,10 @@ func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string
return loaded, errors
}
// loadExtensionFromDirectory loads an extension from an already extracted directory
func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedExtension, error) {
m.mu.Lock()
defer m.mu.Unlock()
// Read manifest
manifestPath := filepath.Join(dirPath, "manifest.json")
manifestData, err := os.ReadFile(manifestPath)
if err != nil {
@@ -450,25 +411,21 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
}
// Check if index.js exists
indexPath := filepath.Join(dirPath, "index.js")
if _, err := os.Stat(indexPath); os.IsNotExist(err) {
return nil, fmt.Errorf("Extension is missing index.js file")
}
// Check if extension already loaded - skip silently (for directory loading on startup)
if existing, exists := m.extensions[manifest.Name]; exists {
GoLog("[Extension] Extension '%s' already loaded, skipping\n", manifest.DisplayName)
return existing, nil
}
// Create data directory for extension
extDataDir := filepath.Join(m.dataDir, manifest.Name)
if err := os.MkdirAll(extDataDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create extension data directory: %w", err)
}
// Create loaded extension
ext := &LoadedExtension{
ID: manifest.Name,
Manifest: manifest,
@@ -526,7 +483,6 @@ func (m *ExtensionManager) RemoveExtension(extensionID string) error {
return nil
}
// UpgradeExtension upgrades an existing extension from a new package file
// Only allows upgrades (new version > current version), not downgrades
func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension, error) {
// Validate file extension
@@ -541,7 +497,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
}
defer zipReader.Close()
// Find and read manifest.json
var manifestData []byte
var hasIndexJS bool
for _, file := range zipReader.File {
@@ -570,13 +525,11 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
return nil, fmt.Errorf("Invalid extension package: index.js not found")
}
// Parse and validate manifest
newManifest, err := ParseManifest(manifestData)
if err != nil {
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
}
// Check if extension exists
m.mu.RLock()
existing, exists := m.extensions[newManifest.Name]
m.mu.RUnlock()
@@ -612,19 +565,15 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
}
}
// Recreate extension directory
if err := os.MkdirAll(extDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create extension directory: %w", err)
}
// Extract all files from new package (preserving directory structure)
for _, file := range zipReader.File {
if file.FileInfo().IsDir() {
continue
}
// Preserve relative path within the zip (support subdirectories)
// Clean the path to prevent path traversal attacks
relPath := filepath.Clean(file.Name)
if strings.HasPrefix(relPath, "..") || filepath.IsAbs(relPath) {
GoLog("[Extension] Skipping unsafe path in archive: %s\n", file.Name)
@@ -632,19 +581,16 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
}
destPath := filepath.Join(extDir, relPath)
// Create parent directories if needed
destDir := filepath.Dir(destPath)
if err := os.MkdirAll(destDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create directory %s: %w", destDir, err)
}
// Create destination file
destFile, err := os.Create(destPath)
if err != nil {
return nil, fmt.Errorf("failed to create file %s: %w", destPath, err)
}
// Copy content
srcFile, err := file.Open()
if err != nil {
destFile.Close()
@@ -659,7 +605,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
}
}
// Create new loaded extension (reusing data directory, preserving enabled state)
ext := &LoadedExtension{
ID: newManifest.Name,
Manifest: newManifest,
@@ -684,7 +629,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
return ext, nil
}
// ExtensionUpgradeInfo holds information about extension upgrade check
type ExtensionUpgradeInfo struct {
ExtensionID string `json:"extension_id"`
CurrentVersion string `json:"current_version"`
@@ -708,7 +652,6 @@ func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte
}
defer zipReader.Close()
// Find and read manifest.json
var manifestData []byte
for _, file := range zipReader.File {
name := filepath.Base(file.Name)
@@ -730,13 +673,11 @@ func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte
return nil, fmt.Errorf("manifest.json not found")
}
// Parse manifest
newManifest, err := ParseManifest(manifestData)
if err != nil {
return nil, fmt.Errorf("Invalid manifest: %w", err)
}
// Check if extension exists
m.mu.RLock()
existing, exists := m.extensions[newManifest.Name]
m.mu.RUnlock()
@@ -752,7 +693,6 @@ func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte
info.CurrentVersion = ""
info.CanUpgrade = false
} else {
// Compare versions
info.CurrentVersion = existing.Manifest.Version
info.CanUpgrade = compareVersions(newManifest.Version, existing.Manifest.Version) > 0
}
@@ -760,7 +700,6 @@ func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte
return info, nil
}
// CheckExtensionUpgradeJSON checks if a package file is an upgrade and returns JSON
func (m *ExtensionManager) CheckExtensionUpgradeJSON(filePath string) (string, error) {
info, err := m.checkExtensionUpgradeInternal(filePath)
if err != nil {
@@ -780,32 +719,32 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
extensions := m.GetAllExtensions()
type ExtensionInfo struct {
ID string `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
Version string `json:"version"`
Author string `json:"author"`
Description string `json:"description"`
Homepage string `json:"homepage,omitempty"`
IconPath string `json:"icon_path,omitempty"`
Types []ExtensionType `json:"types"`
Enabled bool `json:"enabled"`
Status string `json:"status"`
Error string `json:"error_message,omitempty"`
Settings []ExtensionSetting `json:"settings,omitempty"`
QualityOptions []QualityOption `json:"quality_options,omitempty"`
Permissions []string `json:"permissions"`
HasMetadataProvider bool `json:"has_metadata_provider"`
HasDownloadProvider bool `json:"has_download_provider"`
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment"`
SearchBehavior *SearchBehaviorConfig `json:"search_behavior,omitempty"`
TrackMatching *TrackMatchingConfig `json:"track_matching,omitempty"`
PostProcessing *PostProcessingConfig `json:"post_processing,omitempty"`
ID string `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
Version string `json:"version"`
Author string `json:"author"`
Description string `json:"description"`
Homepage string `json:"homepage,omitempty"`
IconPath string `json:"icon_path,omitempty"`
Types []ExtensionType `json:"types"`
Enabled bool `json:"enabled"`
Status string `json:"status"`
Error string `json:"error_message,omitempty"`
Settings []ExtensionSetting `json:"settings,omitempty"`
QualityOptions []QualityOption `json:"quality_options,omitempty"`
Permissions []string `json:"permissions"`
HasMetadataProvider bool `json:"has_metadata_provider"`
HasDownloadProvider bool `json:"has_download_provider"`
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment"`
SearchBehavior *SearchBehaviorConfig `json:"search_behavior,omitempty"`
TrackMatching *TrackMatchingConfig `json:"track_matching,omitempty"`
PostProcessing *PostProcessingConfig `json:"post_processing,omitempty"`
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
}
infos := make([]ExtensionInfo, len(extensions))
for i, ext := range extensions {
// Build permissions list
permissions := []string{}
for _, domain := range ext.Manifest.Permissions.Network {
permissions = append(permissions, "network:"+domain)
@@ -822,7 +761,6 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
status = "disabled"
}
// Check for icon file
iconPath := ""
if ext.Manifest.Icon != "" && ext.SourceDir != "" {
possibleIcon := filepath.Join(ext.SourceDir, ext.Manifest.Icon)
@@ -830,7 +768,6 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
iconPath = possibleIcon
}
}
// Fallback: check for icon.png if not specified in manifest
if iconPath == "" && ext.SourceDir != "" {
possibleIcon := filepath.Join(ext.SourceDir, "icon.png")
if _, err := os.Stat(possibleIcon); err == nil {
@@ -860,6 +797,7 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
SearchBehavior: ext.Manifest.SearchBehavior,
TrackMatching: ext.Manifest.TrackMatching,
PostProcessing: ext.Manifest.PostProcessing,
Capabilities: ext.Manifest.Capabilities,
}
}
@@ -873,7 +811,6 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
// ==================== Extension Lifecycle ====================
// InitializeExtension calls the extension's initialize method with settings
func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[string]interface{}) error {
m.mu.Lock()
defer m.mu.Unlock()
@@ -887,13 +824,11 @@ func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[
return fmt.Errorf("Extension failed to load. Please reinstall the extension")
}
// Convert settings to JSON for passing to JS
settingsJSON, err := json.Marshal(settings)
if err != nil {
return fmt.Errorf("Failed to save settings")
}
// Call initialize function
script := fmt.Sprintf(`
(function() {
var settings = %s;
@@ -917,7 +852,6 @@ func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[
return err
}
// Check result
if result != nil && !goja.IsUndefined(result) {
exported := result.Export()
if resultMap, ok := exported.(map[string]interface{}); ok {
@@ -938,7 +872,6 @@ func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[
return nil
}
// CleanupExtension calls the extension's cleanup method
func (m *ExtensionManager) CleanupExtension(extensionID string) error {
m.mu.Lock()
defer m.mu.Unlock()
@@ -949,10 +882,9 @@ func (m *ExtensionManager) CleanupExtension(extensionID string) error {
}
if ext.VM == nil {
return nil // No VM, nothing to cleanup
return nil
}
// Call cleanup function
script := `
(function() {
if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') {
@@ -973,7 +905,6 @@ func (m *ExtensionManager) CleanupExtension(extensionID string) error {
return err
}
// Check result
if result != nil && !goja.IsUndefined(result) {
exported := result.Export()
if resultMap, ok := exported.(map[string]interface{}); ok {
@@ -1002,11 +933,65 @@ func (m *ExtensionManager) UnloadAllExtensions() {
m.mu.Unlock()
for _, id := range extensionIDs {
// Call cleanup first
m.CleanupExtension(id)
// Then unload
m.UnloadExtension(id)
}
GoLog("[Extension] All extensions unloaded\n")
}
// The function is called as extension.<actionName>() and can return a result
func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (map[string]interface{}, error) {
m.mu.Lock()
defer m.mu.Unlock()
ext, exists := m.extensions[extensionID]
if !exists {
return nil, fmt.Errorf("extension not found: %s", extensionID)
}
if ext.VM == nil {
return nil, fmt.Errorf("extension VM not initialized")
}
if !ext.Enabled {
return nil, fmt.Errorf("extension is disabled")
}
// Call the action function on the extension object
script := fmt.Sprintf(`
(function() {
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
try {
var result = extension.%s();
if (result && typeof result.then === 'function') {
// Handle promise - return pending status
return { success: true, pending: true, message: 'Action started' };
}
return { success: true, result: result };
} catch (e) {
return { success: false, error: e.toString() };
}
}
return { success: false, error: 'Action function not found: %s' };
})()
`, actionName, actionName, actionName)
result, err := RunWithTimeoutAndRecover(ext.VM, script, DefaultJSTimeout)
if err != nil {
GoLog("[Extension] InvokeAction error for %s.%s: %v\n", extensionID, actionName, err)
return nil, fmt.Errorf("action failed: %v", err)
}
if result == nil || goja.IsUndefined(result) {
return map[string]interface{}{"success": true}, nil
}
exported := result.Export()
if resultMap, ok := exported.(map[string]interface{}); ok {
GoLog("[Extension] InvokeAction %s.%s result: %v\n", extensionID, actionName, resultMap)
return resultMap, nil
}
return map[string]interface{}{"success": true, "result": exported}, nil
}
+28 -36
View File
@@ -23,6 +23,7 @@ const (
SettingTypeNumber SettingType = "number"
SettingTypeBool SettingType = "boolean"
SettingTypeSelect SettingType = "select"
SettingTypeButton SettingType = "button" // Action button that calls a JS function
)
// ExtensionPermissions defines what resources an extension can access
@@ -42,6 +43,7 @@ type ExtensionSetting struct {
Secret bool `json:"secret,omitempty"`
Default interface{} `json:"default,omitempty"`
Options []string `json:"options,omitempty"` // For select type
Action string `json:"action,omitempty"` // For button type: JS function name to call (e.g., "startLogin")
}
// QualityOption represents a quality option for download providers
@@ -105,24 +107,25 @@ type PostProcessingConfig struct {
// ExtensionManifest represents the manifest.json of an extension
type ExtensionManifest struct {
Name string `json:"name"`
DisplayName string `json:"displayName"`
Version string `json:"version"`
Author string `json:"author"`
Description string `json:"description"`
Homepage string `json:"homepage,omitempty"`
Icon string `json:"icon,omitempty"` // Icon filename (e.g., "icon.png")
Types []ExtensionType `json:"type"`
Permissions ExtensionPermissions `json:"permissions"`
Settings []ExtensionSetting `json:"settings,omitempty"`
QualityOptions []QualityOption `json:"qualityOptions,omitempty"` // Custom quality options for download providers
MinAppVersion string `json:"minAppVersion,omitempty"`
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"` // If true, don't enrich metadata from Deezer/Spotify
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"` // If true, don't fallback to built-in providers (tidal/qobuz/amazon)
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"` // Custom search behavior
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"` // Custom URL handling
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"` // Custom track matching
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"` // Post-processing hooks
Name string `json:"name"`
DisplayName string `json:"displayName"`
Version string `json:"version"`
Author string `json:"author"`
Description string `json:"description"`
Homepage string `json:"homepage,omitempty"`
Icon string `json:"icon,omitempty"` // Icon filename (e.g., "icon.png")
Types []ExtensionType `json:"type"`
Permissions ExtensionPermissions `json:"permissions"`
Settings []ExtensionSetting `json:"settings,omitempty"`
QualityOptions []QualityOption `json:"qualityOptions,omitempty"` // Custom quality options for download providers
MinAppVersion string `json:"minAppVersion,omitempty"`
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"` // If true, don't enrich metadata from Deezer/Spotify
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"` // If true, don't fallback to built-in providers (tidal/qobuz/amazon)
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"` // Custom search behavior
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"` // Custom URL handling
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"` // Custom track matching
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"` // Post-processing hooks
Capabilities map[string]interface{} `json:"capabilities,omitempty"` // Extension capabilities (homeFeed, browseCategories, etc.)
}
// ManifestValidationError represents a validation error in the manifest
@@ -149,9 +152,7 @@ func ParseManifest(data []byte) (*ExtensionManifest, error) {
return &manifest, nil
}
// Validate checks if the manifest has all required fields and valid values
func (m *ExtensionManifest) Validate() error {
// Check required fields
if strings.TrimSpace(m.Name) == "" {
return &ManifestValidationError{Field: "name", Message: "name is required"}
}
@@ -172,7 +173,6 @@ func (m *ExtensionManifest) Validate() error {
return &ManifestValidationError{Field: "type", Message: "at least one type is required"}
}
// Validate extension types
for _, t := range m.Types {
if t != ExtensionTypeMetadataProvider && t != ExtensionTypeDownloadProvider {
return &ManifestValidationError{
@@ -198,20 +198,6 @@ func (m *ExtensionManifest) Validate() error {
}
}
// Validate setting type
validTypes := map[SettingType]bool{
SettingTypeString: true,
SettingTypeNumber: true,
SettingTypeBool: true,
SettingTypeSelect: true,
}
if !validTypes[setting.Type] {
return &ManifestValidationError{
Field: fmt.Sprintf("settings[%d].type", i),
Message: fmt.Sprintf("invalid setting type: %s", setting.Type),
}
}
// Select type requires options
if setting.Type == SettingTypeSelect && len(setting.Options) == 0 {
return &ManifestValidationError{
@@ -219,6 +205,13 @@ func (m *ExtensionManifest) Validate() error {
Message: "select type requires options",
}
}
if setting.Type == SettingTypeButton && setting.Action == "" {
return &ManifestValidationError{
Field: fmt.Sprintf("settings[%d].action", i),
Message: "button type requires action (JS function name)",
}
}
}
return nil
@@ -289,7 +282,6 @@ func (m *ExtensionManifest) MatchesURL(urlStr string) bool {
return false
}
// Parse URL to get host
urlStr = strings.ToLower(strings.TrimSpace(urlStr))
for _, pattern := range m.URLHandler.Patterns {
pattern = strings.ToLower(strings.TrimSpace(pattern))
+132 -14
View File
@@ -2,6 +2,7 @@
package gobackend
import (
"context"
"encoding/json"
"errors"
"fmt"
@@ -38,6 +39,10 @@ type ExtTrackMetadata struct {
DeezerID string `json:"deezer_id,omitempty"`
SpotifyID string `json:"spotify_id,omitempty"`
ExternalLinks map[string]string `json:"external_links,omitempty"` // service -> URL mapping
// Extended metadata from enrichment (can come from Deezer, Spotify, etc.)
Label string `json:"label,omitempty"` // Record label
Copyright string `json:"copyright,omitempty"` // Copyright information
Genre string `json:"genre,omitempty"` // Music genre(s)
}
// ResolvedCoverURL returns the cover URL, checking both CoverURL and Images fields
@@ -53,6 +58,7 @@ type ExtAlbumMetadata struct {
ID string `json:"id"`
Name string `json:"name"`
Artists string `json:"artists"`
ArtistID string `json:"artist_id,omitempty"`
CoverURL string `json:"cover_url,omitempty"`
ReleaseDate string `json:"release_date,omitempty"`
TotalTracks int `json:"total_tracks"`
@@ -144,6 +150,10 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
// Call extension's searchTracks function
script := fmt.Sprintf(`
(function() {
@@ -189,7 +199,6 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe
}
}
// Set provider ID on all tracks
for i := range searchResult.Tracks {
searchResult.Tracks[i].ProviderID = p.extension.ID
}
@@ -207,6 +216,10 @@ func (p *ExtensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata,
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
script := fmt.Sprintf(`
(function() {
if (typeof extension !== 'undefined' && typeof extension.getTrack === 'function') {
@@ -253,6 +266,10 @@ func (p *ExtensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata,
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
script := fmt.Sprintf(`
(function() {
if (typeof extension !== 'undefined' && typeof extension.getAlbum === 'function') {
@@ -302,6 +319,10 @@ func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
script := fmt.Sprintf(`
(function() {
if (typeof extension !== 'undefined' && typeof extension.getArtist === 'function') {
@@ -350,6 +371,10 @@ func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTra
return track, nil // Extension disabled, return as-is
}
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
// Convert track to JSON for passing to JS
trackJSON, err := json.Marshal(track)
if err != nil {
@@ -416,6 +441,10 @@ func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
script := fmt.Sprintf(`
(function() {
if (typeof extension !== 'undefined' && typeof extension.checkAvailability === 'function') {
@@ -461,6 +490,10 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
script := fmt.Sprintf(`
(function() {
if (typeof extension !== 'undefined' && typeof extension.getDownloadUrl === 'function') {
@@ -509,6 +542,10 @@ func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string,
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
// Set up progress callback in VM
p.vm.Set("__onProgress", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) > 0 {
@@ -737,12 +774,10 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
enrichedTrack, err := provider.EnrichTrack(trackMeta)
if err == nil && enrichedTrack != nil {
// Update request with enriched data
if enrichedTrack.ISRC != "" && enrichedTrack.ISRC != req.ISRC {
GoLog("[DownloadWithExtensionFallback] ISRC enriched: %s -> %s\n", req.ISRC, enrichedTrack.ISRC)
req.ISRC = enrichedTrack.ISRC
}
// Update service-specific IDs from Odesli enrichment
if enrichedTrack.TidalID != "" {
GoLog("[DownloadWithExtensionFallback] Tidal ID from Odesli: %s\n", enrichedTrack.TidalID)
req.TidalID = enrichedTrack.TidalID
@@ -755,13 +790,29 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
GoLog("[DownloadWithExtensionFallback] Deezer ID from Odesli: %s\n", enrichedTrack.DeezerID)
req.DeezerID = enrichedTrack.DeezerID
}
// Can also update other fields if needed
if enrichedTrack.Name != "" {
req.TrackName = enrichedTrack.Name
}
if enrichedTrack.Artists != "" {
req.ArtistName = enrichedTrack.Artists
}
// Copy extended metadata from enrichment (label, copyright, genre, release_date)
if enrichedTrack.Label != "" && req.Label == "" {
GoLog("[DownloadWithExtensionFallback] Label from enrichment: %s\n", enrichedTrack.Label)
req.Label = enrichedTrack.Label
}
if enrichedTrack.Copyright != "" && req.Copyright == "" {
GoLog("[DownloadWithExtensionFallback] Copyright from enrichment: %s\n", enrichedTrack.Copyright)
req.Copyright = enrichedTrack.Copyright
}
if enrichedTrack.Genre != "" && req.Genre == "" {
GoLog("[DownloadWithExtensionFallback] Genre from enrichment: %s\n", enrichedTrack.Genre)
req.Genre = enrichedTrack.Genre
}
if enrichedTrack.ReleaseDate != "" && req.ReleaseDate == "" {
GoLog("[DownloadWithExtensionFallback] ReleaseDate from enrichment: %s\n", enrichedTrack.ReleaseDate)
req.ReleaseDate = enrichedTrack.ReleaseDate
}
}
}
}
@@ -772,7 +823,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
ext, err := extManager.GetExtension(req.Source)
if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsDownloadProvider() {
// Check if this extension wants to skip built-in fallback
skipBuiltIn = ext.Manifest.SkipBuiltInFallback
provider := NewExtensionProviderWrapper(ext)
@@ -783,7 +833,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
GoLog("[DownloadWithExtensionFallback] Downloading from source extension with trackID: %s (skipBuiltInFallback: %v)\n", trackID, skipBuiltIn)
// Build output path
outputPath := buildOutputPath(req)
// Download directly using the track ID from the extension
@@ -801,6 +850,18 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
ActualBitDepth: result.BitDepth,
ActualSampleRate: result.SampleRate,
Service: req.Source,
Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
}
// Embed genre and label if provided (from Deezer metadata)
if req.Genre != "" || req.Label != "" {
if err := EmbedGenreLabel(result.FilePath, req.Genre, req.Label); err != nil {
GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err)
} else {
GoLog("[DownloadWithExtensionFallback] Embedded genre=%q label=%q\n", req.Genre, req.Label)
}
}
// If extension has skipMetadataEnrichment, copy metadata
@@ -884,10 +945,44 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
GoLog("[DownloadWithExtensionFallback] Trying provider: %s\n", providerID)
if isBuiltInProvider(providerID) {
// For built-in providers, enrich with Deezer metadata if not already present
if (req.Genre == "" || req.Label == "") && 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()
extMeta, err := deezerClient.GetExtendedMetadataByISRC(ctx, req.ISRC)
cancel()
if err == nil && extMeta != nil {
if req.Genre == "" && extMeta.Genre != "" {
req.Genre = extMeta.Genre
GoLog("[DownloadWithExtensionFallback] Genre from Deezer: %s\n", req.Genre)
}
if req.Label == "" && extMeta.Label != "" {
req.Label = extMeta.Label
GoLog("[DownloadWithExtensionFallback] Label from Deezer: %s\n", req.Label)
}
} else if err != nil {
GoLog("[DownloadWithExtensionFallback] Failed to get extended metadata from Deezer: %v\n", err)
}
}
// Use built-in provider
result, err := tryBuiltInProvider(providerID, req)
if err == nil && result.Success {
result.Service = providerID
// Copy enriched metadata to response for Flutter (needed for M4A->FLAC conversion)
if req.Label != "" {
result.Label = req.Label
}
if req.Copyright != "" {
result.Copyright = req.Copyright
}
if req.Genre != "" {
result.Genre = req.Genre
}
if req.ReleaseDate != "" && result.ReleaseDate == "" {
result.ReleaseDate = req.ReleaseDate
}
return result, nil
}
if err != nil {
@@ -916,7 +1011,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
provider := NewExtensionProviderWrapper(ext)
// Check availability first
availability, err := provider.CheckAvailability(req.ISRC, req.TrackName, req.ArtistName)
if err != nil || !availability.Available {
GoLog("[DownloadWithExtensionFallback] %s: not available\n", providerID)
@@ -926,12 +1020,9 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
continue
}
// Build output path
outputPath := buildOutputPath(req)
// Download
result, err := provider.Download(availability.TrackID, req.Quality, outputPath, func(percent int) {
// Update progress
if req.ItemID != "" {
SetItemProgress(req.ItemID, float64(percent), 0, 0)
}
@@ -945,6 +1036,18 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
ActualBitDepth: result.BitDepth,
ActualSampleRate: result.SampleRate,
Service: providerID,
Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
}
// Embed genre and label if provided (from Deezer metadata)
if req.Genre != "" || req.Label != "" {
if err := EmbedGenreLabel(result.FilePath, req.Genre, req.Label); err != nil {
GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err)
} else {
GoLog("[DownloadWithExtensionFallback] Embedded genre=%q label=%q\n", req.Genre, req.Label)
}
}
// If extension has skipMetadataEnrichment and returned metadata, use it
@@ -1095,6 +1198,9 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
TrackNumber: result.TrackNumber,
DiscNumber: result.DiscNumber,
ISRC: result.ISRC,
Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
}, nil
}
@@ -1130,6 +1236,10 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
// Convert options to JSON
optionsJSON, _ := json.Marshal(options)
@@ -1171,7 +1281,6 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
tracks = []ExtTrackMetadata{}
}
// Set provider ID on all tracks
for i := range tracks {
tracks[i].ProviderID = p.extension.ID
}
@@ -1202,6 +1311,10 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
script := fmt.Sprintf(`
(function() {
if (typeof extension !== 'undefined' && typeof extension.handleUrl === 'function') {
@@ -1255,7 +1368,6 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e
handleResult.Artist.Albums[i].Tracks[j].ProviderID = p.extension.ID
}
}
// Set provider ID on top tracks
for i := range handleResult.Artist.TopTracks {
handleResult.Artist.TopTracks[i].ProviderID = p.extension.ID
}
@@ -1284,6 +1396,10 @@ func (p *ExtensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{}
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
sourceJSON, _ := json.Marshal(sourceTrack)
candidatesJSON, _ := json.Marshal(candidates)
@@ -1347,6 +1463,10 @@ func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[str
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
metadataJSON, _ := json.Marshal(metadata)
script := fmt.Sprintf(`
@@ -1493,12 +1613,10 @@ func (m *ExtensionManager) RunPostProcessing(filePath string, metadata map[strin
for _, provider := range providers {
hooks := provider.extension.Manifest.GetPostProcessingHooks()
for _, hook := range hooks {
// Check if hook is enabled (TODO: check user settings)
if !hook.DefaultEnabled {
continue
}
// Check if format is supported
ext := strings.ToLower(filepath.Ext(currentPath))
if len(hook.SupportedFormats) > 0 {
supported := false
+5 -30
View File
@@ -1,4 +1,3 @@
// Package gobackend provides extension runtime with sandboxed execution
package gobackend
import (
@@ -10,16 +9,13 @@ import (
"github.com/dop251/goja"
)
// Default timeout for JS execution (30 seconds)
const DefaultJSTimeout = 30 * time.Second
// Global auth state for extensions (stores pending auth codes)
var (
extensionAuthState = make(map[string]*ExtensionAuthState)
extensionAuthStateMu sync.RWMutex
)
// ExtensionAuthState holds auth state for an extension
type ExtensionAuthState struct {
PendingAuthURL string
AuthCode string
@@ -32,14 +28,12 @@ type ExtensionAuthState struct {
PKCEChallenge string
}
// PendingAuthRequest holds a pending OAuth request that needs Flutter to open URL
type PendingAuthRequest struct {
ExtensionID string
AuthURL string
CallbackURL string
}
// Global pending auth requests (Flutter polls this)
var (
pendingAuthRequests = make(map[string]*PendingAuthRequest)
pendingAuthRequestsMu sync.RWMutex
@@ -52,14 +46,12 @@ func GetPendingAuthRequest(extensionID string) *PendingAuthRequest {
return pendingAuthRequests[extensionID]
}
// ClearPendingAuthRequest clears pending auth request (called from Flutter after opening URL)
func ClearPendingAuthRequest(extensionID string) {
pendingAuthRequestsMu.Lock()
defer pendingAuthRequestsMu.Unlock()
delete(pendingAuthRequests, extensionID)
}
// SetExtensionAuthCode sets auth code for an extension (called from Flutter after OAuth callback)
func SetExtensionAuthCode(extensionID string, authCode string) {
extensionAuthStateMu.Lock()
defer extensionAuthStateMu.Unlock()
@@ -72,7 +64,6 @@ func SetExtensionAuthCode(extensionID string, authCode string) {
state.AuthCode = authCode
}
// SetExtensionTokens sets access/refresh tokens for an extension
func SetExtensionTokens(extensionID string, accessToken, refreshToken string, expiresAt time.Time) {
extensionAuthStateMu.Lock()
defer extensionAuthStateMu.Unlock()
@@ -88,7 +79,6 @@ func SetExtensionTokens(extensionID string, accessToken, refreshToken string, ex
state.IsAuthenticated = accessToken != ""
}
// ExtensionRuntime provides sandboxed APIs for extensions
type ExtensionRuntime struct {
extensionID string
manifest *ExtensionManifest
@@ -99,9 +89,7 @@ type ExtensionRuntime struct {
vm *goja.Runtime
}
// NewExtensionRuntime creates a new runtime for an extension
func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
// Create a cookie jar for this extension
jar, _ := newSimpleCookieJar()
runtime := &ExtensionRuntime{
@@ -113,7 +101,6 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
vm: ext.VM,
}
// Create HTTP client with redirect validation to prevent SSRF via open redirect
client := &http.Client{
Timeout: 30 * time.Second,
Jar: jar,
@@ -124,7 +111,6 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
GoLog("[Extension:%s] Redirect blocked: domain '%s' not in allowed list\n", ext.ID, domain)
return &RedirectBlockedError{Domain: domain}
}
// Also block redirects to private/local networks (SSRF protection)
if isPrivateIP(domain) {
GoLog("[Extension:%s] Redirect blocked: private IP '%s'\n", ext.ID, domain)
return &RedirectBlockedError{Domain: domain, IsPrivate: true}
@@ -141,7 +127,6 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
return runtime
}
// RedirectBlockedError is returned when a redirect is blocked due to domain validation
type RedirectBlockedError struct {
Domain string
IsPrivate bool
@@ -167,10 +152,10 @@ func isPrivateIP(host string) bool {
"172.24.", "172.25.", "172.26.", "172.27.",
"172.28.", "172.29.", "172.30.", "172.31.",
"192.168.",
"169.254.", // Link-local
"::1", // IPv6 localhost
"fc00:", // IPv6 private
"fe80:", // IPv6 link-local
"169.254.",
"::1",
"fc00:",
"fe80:",
}
hostLower := host
@@ -188,7 +173,6 @@ func isPrivateIP(host string) bool {
return false
}
// simpleCookieJar is a simple in-memory cookie jar
type simpleCookieJar struct {
cookies map[string][]*http.Cookie
mu sync.RWMutex
@@ -213,7 +197,6 @@ func (j *simpleCookieJar) Cookies(u *url.URL) []*http.Cookie {
return j.cookies[u.Host]
}
// SetSettings updates the runtime settings
func (r *ExtensionRuntime) SetSettings(settings map[string]interface{}) {
r.settings = settings
}
@@ -233,7 +216,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
httpObj.Set("clearCookies", r.httpClearCookies)
vm.Set("http", httpObj)
// Storage API
storageObj := vm.NewObject()
storageObj.Set("get", r.storageGet)
storageObj.Set("set", r.storageSet)
@@ -248,7 +230,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
credentialsObj.Set("has", r.credentialsHas)
vm.Set("credentials", credentialsObj)
// Auth API (for OAuth and other auth flows)
authObj := vm.NewObject()
authObj.Set("openAuthUrl", r.authOpenUrl)
authObj.Set("getAuthCode", r.authGetCode)
@@ -275,7 +256,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
fileObj.Set("getSize", r.fileGetSize)
vm.Set("file", fileObj)
// FFmpeg API (for post-processing)
ffmpegObj := vm.NewObject()
ffmpegObj.Set("execute", r.ffmpegExecute)
ffmpegObj.Set("getInfo", r.ffmpegGetInfo)
@@ -289,7 +269,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
matchingObj.Set("normalizeString", r.matchingNormalizeString)
vm.Set("matching", matchingObj)
// Utilities
utilsObj := vm.NewObject()
utilsObj.Set("base64Encode", r.base64Encode)
utilsObj.Set("base64Decode", r.base64Decode)
@@ -304,6 +283,7 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
utilsObj.Set("encrypt", r.cryptoEncrypt)
utilsObj.Set("decrypt", r.cryptoDecrypt)
utilsObj.Set("generateKey", r.cryptoGenerateKey)
utilsObj.Set("randomUserAgent", r.randomUserAgent)
vm.Set("utils", utilsObj)
// Log object (already set in extension_manager.go, but we can enhance it)
@@ -314,7 +294,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
logObj.Set("error", r.logError)
vm.Set("log", logObj)
// Go backend functions
gobackendObj := vm.NewObject()
gobackendObj.Set("sanitizeFilename", r.sanitizeFilenameWrapper)
vm.Set("gobackend", gobackendObj)
@@ -325,16 +304,12 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
// Global fetch() - Promise-style HTTP API (browser-compatible)
vm.Set("fetch", r.fetchPolyfill)
// Global atob/btoa - Base64 encoding (browser-compatible)
vm.Set("atob", r.atobPolyfill)
vm.Set("btoa", r.btoaPolyfill)
// TextEncoder/TextDecoder constructors
r.registerTextEncoderDecoder(vm)
// URL class for URL parsing
r.registerURLClass(vm)
// JSON global (browser-compatible)
r.registerJSONGlobal(vm)
}
+1 -28
View File
@@ -18,7 +18,6 @@ import (
// ==================== Auth API (OAuth Support) ====================
// authOpenUrl requests Flutter to open an OAuth URL
func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
@@ -33,7 +32,6 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
callbackURL = call.Arguments[1].String()
}
// Store pending auth request for Flutter to pick up
pendingAuthRequestsMu.Lock()
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
ExtensionID: r.extensionID,
@@ -42,7 +40,6 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
}
pendingAuthRequestsMu.Unlock()
// Update auth state
extensionAuthStateMu.Lock()
state, exists := extensionAuthState[r.extensionID]
if !exists {
@@ -50,7 +47,7 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
extensionAuthState[r.extensionID] = state
}
state.PendingAuthURL = authURL
state.AuthCode = "" // Clear any previous auth code
state.AuthCode = ""
extensionAuthStateMu.Unlock()
GoLog("[Extension:%s] Auth URL requested: %s\n", r.extensionID, authURL)
@@ -61,7 +58,6 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
})
}
// authGetCode gets the auth code (set by Flutter after OAuth callback)
func (r *ExtensionRuntime) authGetCode(call goja.FunctionCall) goja.Value {
extensionAuthStateMu.RLock()
defer extensionAuthStateMu.RUnlock()
@@ -114,7 +110,6 @@ func (r *ExtensionRuntime) authSetCode(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(true)
}
// authClear clears all auth state for the extension
func (r *ExtensionRuntime) authClear(call goja.FunctionCall) goja.Value {
extensionAuthStateMu.Lock()
delete(extensionAuthState, r.extensionID)
@@ -138,7 +133,6 @@ func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Valu
return r.vm.ToValue(false)
}
// Check if token is expired
if state.IsAuthenticated && !state.ExpiresAt.IsZero() && time.Now().After(state.ExpiresAt) {
return r.vm.ToValue(false)
}
@@ -146,7 +140,6 @@ func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Valu
return r.vm.ToValue(state.IsAuthenticated)
}
// authGetTokens returns current tokens (for extension to use in API calls)
func (r *ExtensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value {
extensionAuthStateMu.RLock()
defer extensionAuthStateMu.RUnlock()
@@ -182,16 +175,13 @@ func generatePKCEVerifier(length int) (string, error) {
length = 128
}
// Generate random bytes
bytes := make([]byte, length)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
// Use base64url encoding without padding (RFC 7636 compliant)
verifier := base64.RawURLEncoding.EncodeToString(bytes)
// Trim to exact length
if len(verifier) > length {
verifier = verifier[:length]
}
@@ -199,15 +189,12 @@ func generatePKCEVerifier(length int) (string, error) {
return verifier, nil
}
// generatePKCEChallenge generates a code challenge from verifier using S256 method
func generatePKCEChallenge(verifier string) string {
hash := sha256.Sum256([]byte(verifier))
// Base64url encode without padding (RFC 7636)
return base64.RawURLEncoding.EncodeToString(hash[:])
}
// authGeneratePKCE generates a PKCE code verifier and challenge pair
// Returns: { verifier: string, challenge: string, method: "S256" }
func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
// Default length is 64 characters
length := 64
@@ -227,7 +214,6 @@ func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
challenge := generatePKCEChallenge(verifier)
// Store in auth state for later use
extensionAuthStateMu.Lock()
state, exists := extensionAuthState[r.extensionID]
if !exists {
@@ -247,7 +233,6 @@ func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
})
}
// authGetPKCE returns the current PKCE verifier and challenge (if generated)
func (r *ExtensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
extensionAuthStateMu.RLock()
defer extensionAuthStateMu.RUnlock()
@@ -405,7 +390,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
})
}
// Get stored PKCE verifier
extensionAuthStateMu.RLock()
state, exists := extensionAuthState[r.extensionID]
var verifier string
@@ -421,7 +405,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
})
}
// Validate domain
if err := r.validateDomain(tokenURL); err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
@@ -429,7 +412,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
})
}
// Build token request body
formData := url.Values{}
formData.Set("grant_type", "authorization_code")
formData.Set("client_id", clientID)
@@ -439,14 +421,12 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
formData.Set("redirect_uri", redirectURI)
}
// Add extra params
if extraParams, ok := config["extraParams"].(map[string]interface{}); ok {
for k, v := range extraParams {
formData.Set(k, fmt.Sprintf("%v", v))
}
}
// Make token request
req, err := http.NewRequest("POST", tokenURL, strings.NewReader(formData.Encode()))
if err != nil {
return r.vm.ToValue(map[string]interface{}{
@@ -475,7 +455,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
})
}
// Parse response
var tokenResp map[string]interface{}
if err := json.Unmarshal(body, &tokenResp); err != nil {
return r.vm.ToValue(map[string]interface{}{
@@ -485,7 +464,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
})
}
// Check for error in response
if errMsg, ok := tokenResp["error"].(string); ok {
errDesc, _ := tokenResp["error_description"].(string)
return r.vm.ToValue(map[string]interface{}{
@@ -495,7 +473,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
})
}
// Extract tokens
accessToken, _ := tokenResp["access_token"].(string)
refreshToken, _ := tokenResp["refresh_token"].(string)
expiresIn, _ := tokenResp["expires_in"].(float64)
@@ -508,7 +485,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
})
}
// Store tokens in auth state
extensionAuthStateMu.Lock()
state, exists = extensionAuthState[r.extensionID]
if !exists {
@@ -521,14 +497,12 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
if expiresIn > 0 {
state.ExpiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second)
}
// Clear PKCE after successful exchange
state.PKCEVerifier = ""
state.PKCEChallenge = ""
extensionAuthStateMu.Unlock()
GoLog("[Extension:%s] PKCE token exchange successful\n", r.extensionID)
// Return full token response
result := map[string]interface{}{
"success": true,
"access_token": accessToken,
@@ -538,7 +512,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
if expiresIn > 0 {
result["expires_in"] = expiresIn
}
// Include any additional fields from response
if scope, ok := tokenResp["scope"].(string); ok {
result["scope"] = scope
}
-6
View File
@@ -31,14 +31,12 @@ var (
ffmpegCommandID int64
)
// GetPendingFFmpegCommand returns a pending FFmpeg command (called from Flutter)
func GetPendingFFmpegCommand(commandID string) *FFmpegCommand {
ffmpegCommandsMu.RLock()
defer ffmpegCommandsMu.RUnlock()
return ffmpegCommands[commandID]
}
// SetFFmpegCommandResult sets the result of an FFmpeg command (called from Flutter)
func SetFFmpegCommandResult(commandID string, success bool, output, errorMsg string) {
ffmpegCommandsMu.Lock()
defer ffmpegCommandsMu.Unlock()
@@ -50,14 +48,12 @@ func SetFFmpegCommandResult(commandID string, success bool, output, errorMsg str
}
}
// ClearFFmpegCommand removes a completed FFmpeg command
func ClearFFmpegCommand(commandID string) {
ffmpegCommandsMu.Lock()
defer ffmpegCommandsMu.Unlock()
delete(ffmpegCommands, commandID)
}
// ffmpegExecute queues an FFmpeg command for execution by Flutter
func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
@@ -118,7 +114,6 @@ func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
}
}
// ffmpegGetInfo gets audio file information using FFprobe
func (r *ExtensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
@@ -147,7 +142,6 @@ func (r *ExtensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
})
}
// ffmpegConvert is a helper for common conversion operations
func (r *ExtensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{
+1 -39
View File
@@ -21,8 +21,6 @@ var (
allowedDownloadDirsMu sync.RWMutex
)
// SetAllowedDownloadDirs sets the list of directories where extensions can write files
// This should be called by the Go backend when setting up download paths
func SetAllowedDownloadDirs(dirs []string) {
allowedDownloadDirsMu.Lock()
defer allowedDownloadDirsMu.Unlock()
@@ -30,7 +28,6 @@ func SetAllowedDownloadDirs(dirs []string) {
GoLog("[Extension] Allowed download directories set: %v\n", dirs)
}
// AddAllowedDownloadDir adds a directory to the allowed list
func AddAllowedDownloadDir(dir string) {
allowedDownloadDirsMu.Lock()
defer allowedDownloadDirsMu.Unlock()
@@ -40,7 +37,6 @@ func AddAllowedDownloadDir(dir string) {
}
}
// isPathInAllowedDirs checks if an absolute path is within any allowed directory
func isPathInAllowedDirs(absPath string) bool {
allowedDownloadDirsMu.RLock()
defer allowedDownloadDirsMu.RUnlock()
@@ -62,36 +58,28 @@ func (r *ExtensionRuntime) validatePath(path string) (string, error) {
return "", fmt.Errorf("file access denied: extension does not have 'file' permission")
}
// Clean and resolve the path
cleanPath := filepath.Clean(path)
// SECURITY: Block absolute paths by default
// Only allow if path is in explicitly allowed download directories
if filepath.IsAbs(cleanPath) {
absPath, err := filepath.Abs(cleanPath)
if err != nil {
return "", fmt.Errorf("invalid path: %w", err)
}
// Check if path is in allowed download directories
if isPathInAllowedDirs(absPath) {
return absPath, nil
}
// Block all other absolute paths
return "", fmt.Errorf("file access denied: absolute paths are not allowed. Use relative paths within extension sandbox")
}
// For relative paths, join with data directory (extension's sandbox)
fullPath := filepath.Join(r.dataDir, cleanPath)
// Resolve to absolute path
absPath, err := filepath.Abs(fullPath)
if err != nil {
return "", fmt.Errorf("invalid path: %w", err)
}
// Ensure path is within data directory (prevent path traversal)
absDataDir, _ := filepath.Abs(r.dataDir)
if !strings.HasPrefix(absPath, absDataDir) {
return "", fmt.Errorf("file access denied: path '%s' is outside sandbox", path)
@@ -100,8 +88,6 @@ func (r *ExtensionRuntime) validatePath(path string) (string, error) {
return absPath, nil
}
// fileDownload downloads a file from URL to the specified path
// Supports progress callback via options.onProgress
func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{
@@ -113,7 +99,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
urlStr := call.Arguments[0].String()
outputPath := call.Arguments[1].String()
// Validate domain
if err := r.validateDomain(urlStr); err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
@@ -121,7 +106,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
})
}
// Validate output path (allows absolute paths for download queue)
fullPath, err := r.validatePath(outputPath)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
@@ -130,20 +114,17 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
})
}
// Get options if provided
var onProgress goja.Callable
var headers map[string]string
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
optionsObj := call.Arguments[2].Export()
if opts, ok := optionsObj.(map[string]interface{}); ok {
// Extract headers
if h, ok := opts["headers"].(map[string]interface{}); ok {
headers = make(map[string]string)
for k, v := range h {
headers[k] = fmt.Sprintf("%v", v)
}
}
// Extract onProgress callback
if progressVal, ok := opts["onProgress"]; ok {
if callable, ok := goja.AssertFunction(r.vm.ToValue(progressVal)); ok {
onProgress = callable
@@ -152,7 +133,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
}
}
// Create directory if needed
dir := filepath.Dir(fullPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return r.vm.ToValue(map[string]interface{}{
@@ -161,7 +141,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
})
}
// Create HTTP request
req, err := http.NewRequest("GET", urlStr, nil)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
@@ -170,7 +149,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
})
}
// Set headers
for k, v := range headers {
req.Header.Set(k, v)
}
@@ -178,7 +156,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
}
// Download file
resp, err := r.httpClient.Do(req)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
@@ -195,7 +172,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
})
}
// Create output file
out, err := os.Create(fullPath)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
@@ -205,12 +181,10 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
}
defer out.Close()
// Get content length for progress
contentLength := resp.ContentLength
// Copy content with progress reporting
var written int64
buf := make([]byte, 32*1024) // 32KB buffer
buf := make([]byte, 32*1024)
for {
nr, er := resp.Body.Read(buf)
if nr > 0 {
@@ -235,7 +209,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
})
}
// Report progress
if onProgress != nil && contentLength > 0 {
_, _ = onProgress(goja.Undefined(), r.vm.ToValue(written), r.vm.ToValue(contentLength))
}
@@ -260,7 +233,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
})
}
// fileExists checks if a file exists in the sandbox
func (r *ExtensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(false)
@@ -276,7 +248,6 @@ func (r *ExtensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(err == nil)
}
// fileDelete deletes a file in the sandbox
func (r *ExtensionRuntime) fileDelete(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
@@ -306,7 +277,6 @@ func (r *ExtensionRuntime) fileDelete(call goja.FunctionCall) goja.Value {
})
}
// fileRead reads a file from the sandbox
func (r *ExtensionRuntime) fileRead(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
@@ -338,7 +308,6 @@ func (r *ExtensionRuntime) fileRead(call goja.FunctionCall) goja.Value {
})
}
// fileWrite writes data to a file in the sandbox
func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{
@@ -380,7 +349,6 @@ func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
})
}
// fileCopy copies a file within the sandbox
func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{
@@ -408,7 +376,6 @@ func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
})
}
// Read source file
data, err := os.ReadFile(fullSrc)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
@@ -417,7 +384,6 @@ func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
})
}
// Create destination directory if needed
dir := filepath.Dir(fullDst)
if err := os.MkdirAll(dir, 0755); err != nil {
return r.vm.ToValue(map[string]interface{}{
@@ -426,7 +392,6 @@ func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
})
}
// Write to destination
if err := os.WriteFile(fullDst, data, 0644); err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
@@ -440,7 +405,6 @@ func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
})
}
// fileMove moves/renames a file within the sandbox
func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{
@@ -468,7 +432,6 @@ func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
})
}
// Create destination directory if needed
dir := filepath.Dir(fullDst)
if err := os.MkdirAll(dir, 0755); err != nil {
return r.vm.ToValue(map[string]interface{}{
@@ -490,7 +453,6 @@ func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
})
}
// fileGetSize returns the size of a file in bytes
func (r *ExtensionRuntime) fileGetSize(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
-16
View File
@@ -52,7 +52,6 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
urlStr := call.Arguments[0].String()
// Validate domain
if err := r.validateDomain(urlStr); err != nil {
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
return r.vm.ToValue(map[string]interface{}{
@@ -60,7 +59,6 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
})
}
// Get headers if provided
headers := make(map[string]string)
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
headersObj := call.Arguments[1].Export()
@@ -71,7 +69,6 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
}
}
// Create request
req, err := http.NewRequest("GET", urlStr, nil)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
@@ -97,7 +94,6 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
}
defer resp.Body.Close()
// Read body
body, err := io.ReadAll(resp.Body)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
@@ -134,7 +130,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
urlStr := call.Arguments[0].String()
// Validate domain
if err := r.validateDomain(urlStr); err != nil {
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
return r.vm.ToValue(map[string]interface{}{
@@ -175,7 +170,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
}
}
// Create request
req, err := http.NewRequest("POST", urlStr, strings.NewReader(bodyStr))
if err != nil {
return r.vm.ToValue(map[string]interface{}{
@@ -204,7 +198,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
}
defer resp.Body.Close()
// Read body
body, err := io.ReadAll(resp.Body)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
@@ -231,8 +224,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
})
}
// httpRequest performs a generic HTTP request (GET, POST, PUT, DELETE, etc.)
// Usage: http.request(url, options) where options = { method, body, headers }
func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
@@ -242,7 +233,6 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
urlStr := call.Arguments[0].String()
// Validate domain
if err := r.validateDomain(urlStr); err != nil {
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
return r.vm.ToValue(map[string]interface{}{
@@ -326,7 +316,6 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
}
defer resp.Body.Close()
// Read body
body, err := io.ReadAll(resp.Body)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
@@ -354,7 +343,6 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
})
}
// httpPut performs a PUT request (shortcut for http.request with method: "PUT")
func (r *ExtensionRuntime) httpPut(call goja.FunctionCall) goja.Value {
return r.httpMethodShortcut("PUT", call)
}
@@ -364,7 +352,6 @@ func (r *ExtensionRuntime) httpDelete(call goja.FunctionCall) goja.Value {
return r.httpMethodShortcut("DELETE", call)
}
// httpPatch performs a PATCH request (shortcut for http.request with method: "PATCH")
func (r *ExtensionRuntime) httpPatch(call goja.FunctionCall) goja.Value {
return r.httpMethodShortcut("PATCH", call)
}
@@ -380,7 +367,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
urlStr := call.Arguments[0].String()
// Validate domain
if err := r.validateDomain(urlStr); err != nil {
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
return r.vm.ToValue(map[string]interface{}{
@@ -465,7 +451,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
}
defer resp.Body.Close()
// Read body
body, err := io.ReadAll(resp.Body)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
@@ -492,7 +477,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
})
}
// httpClearCookies clears all cookies for this extension
func (r *ExtensionRuntime) httpClearCookies(call goja.FunctionCall) goja.Value {
if jar, ok := r.cookieJar.(*simpleCookieJar); ok {
jar.mu.Lock()
-4
View File
@@ -143,19 +143,16 @@ func (r *ExtensionRuntime) getSaltPath() string {
func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) {
saltPath := r.getSaltPath()
// Try to read existing salt
salt, err := os.ReadFile(saltPath)
if err == nil && len(salt) == 32 {
return salt, nil
}
// Generate new random salt (32 bytes)
salt = make([]byte, 32)
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
return nil, fmt.Errorf("failed to generate salt: %w", err)
}
// Save salt to file
if err := os.WriteFile(saltPath, salt, 0600); err != nil {
return nil, fmt.Errorf("failed to save salt: %w", err)
}
@@ -214,7 +211,6 @@ func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error {
return err
}
// Encrypt the data
key, err := r.getEncryptionKey()
if err != nil {
return fmt.Errorf("failed to get encryption key: %w", err)
+26 -3
View File
@@ -12,6 +12,7 @@ import (
"encoding/json"
"fmt"
"strings"
"time"
"github.com/dop251/goja"
)
@@ -94,7 +95,6 @@ func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
return r.vm.ToValue([]byte{})
}
// Get key - can be string or array of bytes
var keyBytes []byte
keyArg := call.Arguments[0].Export()
switch k := keyArg.(type) {
@@ -113,7 +113,6 @@ func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
return r.vm.ToValue([]byte{})
}
// Get message - can be string or array of bytes
var msgBytes []byte
msgArg := call.Arguments[1].Export()
switch m := msgArg.(type) {
@@ -136,7 +135,6 @@ func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
mac.Write(msgBytes)
result := mac.Sum(nil)
// Convert to array of numbers for JavaScript
jsArray := make([]interface{}, len(result))
for i, b := range result {
jsArray[i] = int(b)
@@ -268,6 +266,11 @@ func (r *ExtensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value
})
}
// randomUserAgent returns a random Chrome User-Agent string
func (r *ExtensionRuntime) randomUserAgent(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(getRandomUserAgent())
}
// ==================== Logging Functions ====================
func (r *ExtensionRuntime) logDebug(call goja.FunctionCall) goja.Value {
@@ -369,4 +372,24 @@ func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
return vm.ToValue(buildFilenameFromTemplate(template, metadata))
})
// Expose getLocalTime - returns device local time info
obj.Set("getLocalTime", func(call goja.FunctionCall) goja.Value {
now := time.Now()
_, offsetSeconds := now.Zone()
offsetMinutes := offsetSeconds / 60
return vm.ToValue(map[string]interface{}{
"year": now.Year(),
"month": int(now.Month()),
"day": now.Day(),
"hour": now.Hour(),
"minute": now.Minute(),
"second": now.Second(),
"weekday": int(now.Weekday()),
"offsetMinutes": -offsetMinutes, // JS convention: negative for east of UTC
"timezone": now.Location().String(),
"timestamp": now.Unix(),
})
})
}
-4
View File
@@ -42,7 +42,6 @@ func (s *ExtensionSettingsStore) SetDataDir(dataDir string) error {
return fmt.Errorf("failed to create settings directory: %w", err)
}
// Load all existing settings
return s.loadAllSettings()
}
@@ -99,7 +98,6 @@ func (s *ExtensionSettingsStore) loadSettings(extensionID string) (map[string]in
func (s *ExtensionSettingsStore) saveSettings(extensionID string, settings map[string]interface{}) error {
settingsPath := s.getSettingsPath(extensionID)
// Create directory if needed
dir := filepath.Dir(settingsPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
@@ -160,7 +158,6 @@ func (s *ExtensionSettingsStore) Set(extensionID, key string, value interface{})
s.settings[extensionID][key] = value
// Persist to disk
return s.saveSettings(extensionID, s.settings[extensionID])
}
@@ -198,7 +195,6 @@ func (s *ExtensionSettingsStore) RemoveAll(extensionID string) error {
delete(s.settings, extensionID)
// Remove settings file
settingsPath := s.getSettingsPath(extensionID)
if err := os.Remove(settingsPath); err != nil && !os.IsNotExist(err) {
return err
-2
View File
@@ -35,7 +35,6 @@ type StoreExtension struct {
Downloads int `json:"downloads"`
UpdatedAt string `json:"updated_at"`
MinAppVersion string `json:"min_app_version,omitempty"`
// Alternative camelCase fields (for flexibility)
DisplayNameAlt string `json:"displayName,omitempty"`
DownloadURLAlt string `json:"downloadUrl,omitempty"`
IconURLAlt string `json:"iconUrl,omitempty"`
@@ -332,7 +331,6 @@ func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string)
return fmt.Errorf("download returned HTTP %d", resp.StatusCode)
}
// Create destination file
out, err := os.Create(destPath)
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
-11
View File
@@ -6,28 +6,21 @@ import (
"strings"
)
// Invalid filename characters for Android/Windows/Linux
var invalidChars = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]`)
// sanitizeFilename removes invalid characters from filename
func sanitizeFilename(filename string) string {
// Replace invalid characters with underscore
sanitized := invalidChars.ReplaceAllString(filename, "_")
// Remove leading/trailing spaces and dots
sanitized = strings.TrimSpace(sanitized)
sanitized = strings.Trim(sanitized, ".")
// Collapse multiple underscores
multiUnderscore := regexp.MustCompile(`_+`)
sanitized = multiUnderscore.ReplaceAllString(sanitized, "_")
// Limit length (Android has 255 byte limit for filenames)
if len(sanitized) > 200 {
sanitized = sanitized[:200]
}
// Ensure not empty
if sanitized == "" {
sanitized = "untitled"
}
@@ -35,7 +28,6 @@ func sanitizeFilename(filename string) string {
return sanitized
}
// buildFilenameFromTemplate builds a filename from template and metadata
func buildFilenameFromTemplate(template string, metadata map[string]interface{}) string {
if template == "" {
template = "{artist} - {title}"
@@ -43,7 +35,6 @@ func buildFilenameFromTemplate(template string, metadata map[string]interface{})
result := template
// Replace placeholders
placeholders := map[string]string{
"{title}": getString(metadata, "title"),
"{artist}": getString(metadata, "artist"),
@@ -63,7 +54,6 @@ func buildFilenameFromTemplate(template string, metadata map[string]interface{})
func getString(m map[string]interface{}, key string) string {
if v, ok := m[key]; ok {
if s, ok := v.(string); ok {
// Trim leading/trailing whitespace to prevent filename issues
return strings.TrimSpace(s)
}
}
@@ -98,7 +88,6 @@ func formatDiscNumber(n int) string {
return fmt.Sprintf("%d", n)
}
// extractYear extracts year from date string (YYYY-MM-DD or YYYY)
func extractYear(date string) string {
if len(date) >= 4 {
return date[:4]
+16 -76
View File
@@ -15,76 +15,32 @@ import (
"time"
)
// HTTP utility functions for consistent request handling across all downloaders
// getRandomUserAgent generates a random Windows Chrome User-Agent string
// Uses same format as PC version (referensi/backend/spotify_metadata.go) for better API compatibility
// Uses modern Chrome format with build and patch numbers
// Windows 11 still reports as "Windows NT 10.0" for compatibility
func getRandomUserAgent() string {
// Windows 10/11 Chrome format - same as PC version for maximum compatibility
// Some APIs may block mobile User-Agents, so we use desktop format
winMajor := rand.Intn(2) + 10 // Windows 10 or 11
chromeVersion := rand.Intn(25) + 100 // Chrome 100-124
chromeBuild := rand.Intn(1500) + 3000 // Build 3000-4500
chromePatch := rand.Intn(65) + 60 // Patch 60-125
// Chrome version 120-145 (modern range)
chromeVersion := rand.Intn(26) + 120
chromeBuild := rand.Intn(1500) + 6000
chromePatch := rand.Intn(200) + 100
return fmt.Sprintf(
"Mozilla/5.0 (Windows NT %d.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/537.36",
winMajor,
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/537.36",
chromeVersion,
chromeBuild,
chromePatch,
)
}
// getRandomMacUserAgent generates a random Mac Chrome User-Agent string
// Alternative format matching referensi/backend/spotify_metadata.go exactly
// Kept for potential future use
// func getRandomMacUserAgent() string {
// macMajor := rand.Intn(4) + 11 // macOS 11-14
// macMinor := rand.Intn(5) + 4 // Minor 4-8
// webkitMajor := rand.Intn(7) + 530
// webkitMinor := rand.Intn(7) + 30
// chromeMajor := rand.Intn(25) + 80
// chromeBuild := rand.Intn(1500) + 3000
// chromePatch := rand.Intn(65) + 60
// safariMajor := rand.Intn(7) + 530
// safariMinor := rand.Intn(6) + 30
//
// return fmt.Sprintf(
// "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_%d_%d) AppleWebKit/%d.%d (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/%d.%d",
// macMajor,
// macMinor,
// webkitMajor,
// webkitMinor,
// chromeMajor,
// chromeBuild,
// chromePatch,
// safariMajor,
// safariMinor,
// )
// }
// getRandomDesktopUserAgent randomly picks between Windows and Mac User-Agent
// Kept for potential future use
// func getRandomDesktopUserAgent() string {
// if rand.Intn(2) == 0 {
// return getRandomUserAgent() // Windows
// }
// return getRandomMacUserAgent() // Mac
// }
// Default timeout values
const (
DefaultTimeout = 60 * time.Second // Default HTTP timeout
DownloadTimeout = 120 * time.Second // Timeout for file downloads
SongLinkTimeout = 30 * time.Second // Timeout for SongLink API
DefaultMaxRetries = 3 // Default retry count
DefaultRetryDelay = 1 * time.Second // Initial retry delay
DefaultTimeout = 60 * time.Second
DownloadTimeout = 120 * time.Second
SongLinkTimeout = 30 * time.Second
DefaultMaxRetries = 3
DefaultRetryDelay = 1 * time.Second
)
// Shared transport with connection pooling to prevent TCP exhaustion
// Optimized for large file downloads (FLAC ~30-50MB)
var sharedTransport = &http.Transport{
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
@@ -96,27 +52,23 @@ var sharedTransport = &http.Transport{
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
DisableKeepAlives: false, // Enable keep-alives for connection reuse
DisableKeepAlives: false,
ForceAttemptHTTP2: true,
WriteBufferSize: 64 * 1024, // 64KB write buffer
ReadBufferSize: 64 * 1024, // 64KB read buffer
DisableCompression: true, // FLAC is already compressed
WriteBufferSize: 64 * 1024,
ReadBufferSize: 64 * 1024,
DisableCompression: true,
}
// Shared HTTP client for general requests (reuses connections)
var sharedClient = &http.Client{
Transport: sharedTransport,
Timeout: DefaultTimeout,
}
// Shared HTTP client for downloads (longer timeout, reuses connections)
var downloadClient = &http.Client{
Transport: sharedTransport,
Timeout: DownloadTimeout,
}
// NewHTTPClientWithTimeout creates an HTTP client with specified timeout
// Uses shared transport for connection reuse
func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client {
return &http.Client{
Transport: sharedTransport,
@@ -124,29 +76,24 @@ func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client {
}
}
// GetSharedClient returns the shared HTTP client for general requests
func GetSharedClient() *http.Client {
return sharedClient
}
// GetDownloadClient returns the shared HTTP client for downloads
func GetDownloadClient() *http.Client {
return downloadClient
}
// CloseIdleConnections closes idle connections in the shared transport
// Call this periodically during large batch downloads to prevent connection buildup
func CloseIdleConnections() {
sharedTransport.CloseIdleConnections()
}
// DoRequestWithUserAgent executes an HTTP request with a random User-Agent header
// Also checks for ISP blocking on errors
func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := client.Do(req)
if err != nil {
// Check for ISP blocking
CheckAndLogISPBlocking(err, req.URL.String(), "HTTP")
}
return resp, err
@@ -160,7 +107,6 @@ type RetryConfig struct {
BackoffFactor float64
}
// DefaultRetryConfig returns default retry configuration
func DefaultRetryConfig() RetryConfig {
return RetryConfig{
MaxRetries: DefaultMaxRetries,
@@ -266,13 +212,11 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
return nil, fmt.Errorf("request failed after %d retries: %w", config.MaxRetries+1, lastErr)
}
// calculateNextDelay calculates the next delay with exponential backoff
func calculateNextDelay(currentDelay time.Duration, config RetryConfig) time.Duration {
nextDelay := time.Duration(float64(currentDelay) * config.BackoffFactor)
return min(nextDelay, config.MaxDelay)
}
// getRetryAfterDuration parses Retry-After header and returns duration
// Returns 60 seconds as default if header is missing or invalid
func getRetryAfterDuration(resp *http.Response) time.Duration {
retryAfter := resp.Header.Get("Retry-After")
@@ -315,7 +259,6 @@ func ReadResponseBody(resp *http.Response) ([]byte, error) {
return body, nil
}
// ValidateResponse checks if response is valid (non-nil, status 2xx)
func ValidateResponse(resp *http.Response) error {
if resp == nil {
return fmt.Errorf("response is nil")
@@ -344,7 +287,6 @@ func BuildErrorMessage(apiURL string, statusCode int, responsePreview string) st
return msg
}
// ISPBlockingError represents an error caused by ISP blocking
type ISPBlockingError struct {
Domain string
Reason string
@@ -460,7 +402,6 @@ func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
return nil
}
// CheckAndLogISPBlocking checks for ISP blocking and logs if detected
// Returns true if ISP blocking was detected
func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool {
ispErr := IsISPBlocking(err, requestURL)
@@ -498,7 +439,6 @@ func extractDomain(rawURL string) string {
return "unknown"
}
// WrapErrorWithISPCheck wraps an error with ISP blocking detection
// If ISP blocking is detected, returns a more descriptive error
func WrapErrorWithISPCheck(err error, requestURL string, tag string) error {
if err == nil {
+1 -9
View File
@@ -8,7 +8,6 @@ import (
"time"
)
// LogEntry represents a single log entry
type LogEntry struct {
Timestamp string `json:"timestamp"`
Level string `json:"level"`
@@ -16,12 +15,11 @@ type LogEntry struct {
Message string `json:"message"`
}
// LogBuffer stores logs in a circular buffer for retrieval by Flutter
type LogBuffer struct {
entries []LogEntry
maxSize int
mu sync.RWMutex
loggingEnabled bool // Whether logging is enabled (controlled by Flutter)
loggingEnabled bool
}
var (
@@ -41,7 +39,6 @@ func GetLogBuffer() *LogBuffer {
return globalLogBuffer
}
// SetLoggingEnabled enables or disables logging
func (lb *LogBuffer) SetLoggingEnabled(enabled bool) {
lb.mu.Lock()
defer lb.mu.Unlock()
@@ -55,12 +52,10 @@ func (lb *LogBuffer) IsLoggingEnabled() bool {
return lb.loggingEnabled
}
// Add adds a log entry to the buffer
func (lb *LogBuffer) Add(level, tag, message string) {
lb.mu.Lock()
defer lb.mu.Unlock()
// Skip if logging is disabled (except for errors which are always logged)
if !lb.loggingEnabled && level != "ERROR" && level != "FATAL" {
return
}
@@ -73,12 +68,10 @@ func (lb *LogBuffer) Add(level, tag, message string) {
}
if len(lb.entries) >= lb.maxSize {
// Remove oldest entry
lb.entries = lb.entries[1:]
}
lb.entries = append(lb.entries, entry)
// Also print to logcat for debugging
fmt.Printf("[%s] %s\n", tag, message)
}
@@ -91,7 +84,6 @@ func (lb *LogBuffer) GetAll() string {
return string(jsonBytes)
}
// getSince returns log entries since the given index (internal use)
func (lb *LogBuffer) getSince(index int) ([]LogEntry, int) {
lb.mu.RLock()
defer lb.mu.RUnlock()
+158 -17
View File
@@ -3,14 +3,93 @@ package gobackend
import (
"encoding/json"
"fmt"
"math"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
)
const (
lyricsCacheTTL = 24 * time.Hour
durationToleranceSec = 10.0
)
type lyricsCacheEntry struct {
response *LyricsResponse
expiresAt time.Time
}
type lyricsCache struct {
mu sync.RWMutex
cache map[string]*lyricsCacheEntry
}
var globalLyricsCache = &lyricsCache{
cache: make(map[string]*lyricsCacheEntry),
}
func (c *lyricsCache) generateKey(artist, track string, durationSec float64) string {
normalizedArtist := strings.ToLower(strings.TrimSpace(artist))
normalizedTrack := strings.ToLower(strings.TrimSpace(track))
roundedDuration := math.Round(durationSec/10) * 10
return fmt.Sprintf("%s|%s|%.0f", normalizedArtist, normalizedTrack, roundedDuration)
}
func (c *lyricsCache) Get(artist, track string, durationSec float64) (*LyricsResponse, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
key := c.generateKey(artist, track, durationSec)
entry, exists := c.cache[key]
if !exists {
return nil, false
}
if time.Now().After(entry.expiresAt) {
return nil, false
}
return entry.response, true
}
func (c *lyricsCache) Set(artist, track string, durationSec float64, response *LyricsResponse) {
c.mu.Lock()
defer c.mu.Unlock()
key := c.generateKey(artist, track, durationSec)
c.cache[key] = &lyricsCacheEntry{
response: response,
expiresAt: time.Now().Add(lyricsCacheTTL),
}
}
func (c *lyricsCache) CleanExpired() int {
c.mu.Lock()
defer c.mu.Unlock()
now := time.Now()
cleaned := 0
for key, entry := range c.cache {
if now.After(entry.expiresAt) {
delete(c.cache, key)
cleaned++
}
}
return cleaned
}
func (c *lyricsCache) Size() int {
c.mu.RLock()
defer c.mu.RUnlock()
return len(c.cache)
}
type LRCLibResponse struct {
ID int `json:"id"`
Name string `json:"name"`
@@ -44,9 +123,7 @@ type LyricsClient struct {
func NewLyricsClient() *LyricsClient {
return &LyricsClient{
httpClient: &http.Client{
Timeout: 15 * time.Second,
},
httpClient: NewHTTPClientWithTimeout(15 * time.Second),
}
}
@@ -86,7 +163,7 @@ func (c *LyricsClient) FetchLyricsWithMetadata(artist, track string) (*LyricsRes
return c.parseLRCLibResponse(&lrcResp), nil
}
func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string) (*LyricsResponse, error) {
func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string, durationSec float64) (*LyricsResponse, error) {
baseURL := "https://lrclib.net/api/search"
params := url.Values{}
params.Set("q", query)
@@ -118,6 +195,11 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string) (*LyricsRespons
return nil, fmt.Errorf("no lyrics found")
}
bestMatch := c.findBestMatch(results, durationSec)
if bestMatch != nil {
return c.parseLRCLibResponse(bestMatch), nil
}
for _, result := range results {
if result.SyncedLyrics != "" {
return c.parseLRCLibResponse(&result), nil
@@ -127,38 +209,83 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string) (*LyricsRespons
return c.parseLRCLibResponse(&results[0]), nil
}
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string) (*LyricsResponse, error) {
// Strategy 1: Direct match with artist and track name
lyrics, err := c.FetchLyricsWithMetadata(artistName, trackName)
func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec float64) *LRCLibResponse {
var bestSynced *LRCLibResponse
var bestPlain *LRCLibResponse
for i := range results {
result := &results[i]
durationMatches := targetDurationSec == 0 || c.durationMatches(result.Duration, targetDurationSec)
if durationMatches {
if result.SyncedLyrics != "" && bestSynced == nil {
bestSynced = result
} else if result.PlainLyrics != "" && bestPlain == nil {
bestPlain = result
}
}
}
if bestSynced != nil {
return bestSynced
}
return bestPlain
}
func (c *LyricsClient) durationMatches(lrcDuration, targetDuration float64) bool {
diff := math.Abs(lrcDuration - targetDuration)
return diff <= durationToleranceSec
}
// durationSec: track duration in seconds for matching, use 0 to skip duration matching
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
// Check cache first
if cached, found := globalLyricsCache.Get(artistName, trackName, durationSec); found {
fmt.Printf("[Lyrics] Cache hit for: %s - %s\n", artistName, trackName)
cachedCopy := *cached
cachedCopy.Source = cached.Source + " (cached)"
return &cachedCopy, nil
}
var lyrics *LyricsResponse
var err error
// Try exact match first
lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName)
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
lyrics.Source = "LRCLIB"
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil
}
// Strategy 2: Try with simplified track name
// Try with simplified track name
simplifiedTrack := simplifyTrackName(trackName)
if simplifiedTrack != trackName {
lyrics, err = c.FetchLyricsWithMetadata(artistName, simplifiedTrack)
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
lyrics.Source = "LRCLIB (simplified)"
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil
}
}
// Strategy 3: Search with full query
// Search with duration matching
query := artistName + " " + trackName
lyrics, err = c.FetchLyricsFromLRCLibSearch(query)
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
lyrics.Source = "LRCLIB Search"
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil
}
// Strategy 4: Search with simplified query
// Search with simplified name and duration matching
if simplifiedTrack != trackName {
query = artistName + " " + simplifiedTrack
lyrics, err = c.FetchLyricsFromLRCLibSearch(query)
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
lyrics.Source = "LRCLIB Search (simplified)"
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil
}
}
@@ -248,7 +375,6 @@ func msToLRCTimestamp(ms int64) string {
return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds)
}
// convertToLRC converts lyrics to LRC format string (without metadata headers)
// Use convertToLRCWithMetadata for full LRC with headers
// Kept for potential future use
// func convertToLRC(lyrics *LyricsResponse) string {
@@ -275,8 +401,6 @@ func msToLRCTimestamp(ms int64) string {
// return builder.String()
// }
// convertToLRCWithMetadata converts lyrics to LRC format with metadata headers
// Includes [ti:], [ar:], [by:] headers
func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName string) string {
if lyrics == nil || len(lyrics.Lines) == 0 {
return ""
@@ -284,13 +408,11 @@ func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName stri
var builder strings.Builder
// Add metadata headers
builder.WriteString(fmt.Sprintf("[ti:%s]\n", trackName))
builder.WriteString(fmt.Sprintf("[ar:%s]\n", artistName))
builder.WriteString("[by:SpotiFLAC-Mobile]\n")
builder.WriteString("\n")
// Add lyrics lines
if lyrics.SyncType == "LINE_SYNCED" {
for _, line := range lyrics.Lines {
if line.Words == "" {
@@ -339,3 +461,22 @@ func simplifyTrackName(name string) string {
return strings.TrimSpace(result)
}
func SaveLRCFile(audioFilePath, lrcContent string) (string, error) {
if lrcContent == "" {
return "", fmt.Errorf("empty LRC content")
}
dir := filepath.Dir(audioFilePath)
ext := filepath.Ext(audioFilePath)
baseName := strings.TrimSuffix(filepath.Base(audioFilePath), ext)
lrcFilePath := filepath.Join(dir, baseName+".lrc")
if err := os.WriteFile(lrcFilePath, []byte(lrcContent), 0644); err != nil {
return "", fmt.Errorf("failed to write LRC file: %w", err)
}
GoLog("[Lyrics] Saved LRC file: %s\n", lrcFilePath)
return lrcFilePath, nil
}
+440 -160
View File
@@ -1,7 +1,10 @@
package gobackend
import (
"bytes"
"encoding/binary"
"fmt"
"io"
"os"
"strconv"
"strings"
@@ -11,7 +14,6 @@ import (
"github.com/go-flac/go-flac"
)
// Metadata represents track metadata for embedding
type Metadata struct {
Title string
Artist string
@@ -24,16 +26,17 @@ type Metadata struct {
ISRC string
Description string
Lyrics string
Genre string
Label string
Copyright string
}
// EmbedMetadata embeds metadata into a FLAC file
func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
f, err := flac.ParseFile(filePath)
if err != nil {
return fmt.Errorf("failed to parse FLAC file: %w", err)
}
// Find or create vorbis comment block
var cmtIdx int = -1
var cmt *flacvorbis.MetaDataBlockVorbisComment
@@ -52,7 +55,6 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
cmt = flacvorbis.New()
}
// Set metadata fields
setComment(cmt, "TITLE", metadata.Title)
setComment(cmt, "ARTIST", metadata.Artist)
setComment(cmt, "ALBUM", metadata.Album)
@@ -84,7 +86,18 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
setComment(cmt, "UNSYNCEDLYRICS", metadata.Lyrics)
}
// Update or add vorbis comment block
if metadata.Genre != "" {
setComment(cmt, "GENRE", metadata.Genre)
}
if metadata.Label != "" {
setComment(cmt, "ORGANIZATION", metadata.Label)
}
if metadata.Copyright != "" {
setComment(cmt, "COPYRIGHT", metadata.Copyright)
}
cmtBlock := cmt.Marshal()
if cmtIdx >= 0 {
f.Meta[cmtIdx] = &cmtBlock
@@ -92,14 +105,12 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
f.Meta = append(f.Meta, &cmtBlock)
}
// Add cover art if provided
if coverPath != "" {
if fileExists(coverPath) {
coverData, err := os.ReadFile(coverPath)
if err != nil {
fmt.Printf("[Metadata] Warning: Failed to read cover file %s: %v\n", coverPath, err)
} else {
// Remove existing picture blocks first (like PC version)
for i := len(f.Meta) - 1; i >= 0; i-- {
if f.Meta[i].Type == flac.Picture {
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
@@ -125,19 +136,15 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
}
}
// Save file
return f.Save(filePath)
}
// EmbedMetadataWithCoverData embeds metadata into a FLAC file with cover data as bytes
// This avoids file permission issues on Android by not requiring a temp file
func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []byte) error {
f, err := flac.ParseFile(filePath)
if err != nil {
return fmt.Errorf("failed to parse FLAC file: %w", err)
}
// Find or create vorbis comment block
var cmtIdx int = -1
var cmt *flacvorbis.MetaDataBlockVorbisComment
@@ -156,7 +163,6 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
cmt = flacvorbis.New()
}
// Set metadata fields
setComment(cmt, "TITLE", metadata.Title)
setComment(cmt, "ARTIST", metadata.Artist)
setComment(cmt, "ALBUM", metadata.Album)
@@ -188,7 +194,18 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
setComment(cmt, "UNSYNCEDLYRICS", metadata.Lyrics)
}
// Update or add vorbis comment block
if metadata.Genre != "" {
setComment(cmt, "GENRE", metadata.Genre)
}
if metadata.Label != "" {
setComment(cmt, "ORGANIZATION", metadata.Label)
}
if metadata.Copyright != "" {
setComment(cmt, "COPYRIGHT", metadata.Copyright)
}
cmtBlock := cmt.Marshal()
if cmtIdx >= 0 {
f.Meta[cmtIdx] = &cmtBlock
@@ -196,9 +213,7 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
f.Meta = append(f.Meta, &cmtBlock)
}
// Add cover art if provided
if len(coverData) > 0 {
// Remove existing picture blocks first
for i := len(f.Meta) - 1; i >= 0; i-- {
if f.Meta[i].Type == flac.Picture {
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
@@ -220,7 +235,6 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
}
}
// Save file
return f.Save(filePath)
}
@@ -257,7 +271,6 @@ func ReadMetadata(filePath string) (*Metadata, error) {
if trackNum != "" {
fmt.Sscanf(trackNum, "%d", &metadata.TrackNumber)
}
// Also try lowercase variant (some encoders use lowercase)
if metadata.TrackNumber == 0 {
trackNum = getComment(cmt, "TRACK")
if trackNum != "" {
@@ -269,7 +282,6 @@ func ReadMetadata(filePath string) (*Metadata, error) {
if discNum != "" {
fmt.Sscanf(discNum, "%d", &metadata.DiscNumber)
}
// Also try DISC variant
if metadata.DiscNumber == 0 {
discNum = getComment(cmt, "DISC")
if discNum != "" {
@@ -277,7 +289,6 @@ func ReadMetadata(filePath string) (*Metadata, error) {
}
}
// Try DATE variants
if metadata.Date == "" {
metadata.Date = getComment(cmt, "YEAR")
}
@@ -293,7 +304,6 @@ func setComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) {
if value == "" {
return
}
// Remove existing (case-insensitive comparison for Vorbis comments)
keyUpper := strings.ToUpper(key)
for i := len(cmt.Comments) - 1; i >= 0; i-- {
comment := cmt.Comments[i]
@@ -305,7 +315,6 @@ func setComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) {
}
}
}
// Add new
cmt.Comments = append(cmt.Comments, key+"="+value)
}
@@ -313,7 +322,6 @@ func getComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) string {
keyUpper := strings.ToUpper(key) + "="
for _, comment := range cmt.Comments {
if len(comment) > len(key) {
// Case-insensitive comparison for Vorbis comments
commentUpper := strings.ToUpper(comment[:len(key)+1])
if commentUpper == keyUpper {
return comment[len(key)+1:]
@@ -323,13 +331,11 @@ func getComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) string {
return ""
}
// fileExists checks if a file exists
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// EmbedLyrics embeds lyrics into a FLAC file as a separate operation
func EmbedLyrics(filePath string, lyrics string) error {
f, err := flac.ParseFile(filePath)
if err != nil {
@@ -367,6 +373,51 @@ func EmbedLyrics(filePath string, lyrics string) error {
return f.Save(filePath)
}
func EmbedGenreLabel(filePath string, genre, label string) error {
if genre == "" && label == "" {
return nil
}
f, err := flac.ParseFile(filePath)
if err != nil {
return fmt.Errorf("failed to parse FLAC file: %w", err)
}
var cmtIdx int = -1
var cmt *flacvorbis.MetaDataBlockVorbisComment
for idx, meta := range f.Meta {
if meta.Type == flac.VorbisComment {
cmtIdx = idx
cmt, err = flacvorbis.ParseFromMetaDataBlock(*meta)
if err != nil {
return fmt.Errorf("failed to parse vorbis comment: %w", err)
}
break
}
}
if cmt == nil {
cmt = flacvorbis.New()
}
if genre != "" {
setComment(cmt, "GENRE", genre)
}
if label != "" {
setComment(cmt, "ORGANIZATION", label)
}
cmtBlock := cmt.Marshal()
if cmtIdx >= 0 {
f.Meta[cmtIdx] = &cmtBlock
} else {
f.Meta = append(f.Meta, &cmtBlock)
}
return f.Save(filePath)
}
// ExtractLyrics extracts embedded lyrics from a FLAC file
func ExtractLyrics(filePath string) (string, error) {
f, err := flac.ParseFile(filePath)
@@ -381,13 +432,11 @@ func ExtractLyrics(filePath string) (string, error) {
continue
}
// Try LYRICS tag first
lyrics, err := cmt.Get("LYRICS")
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
return lyrics[0], nil
}
// Fallback to UNSYNCEDLYRICS
lyrics, err = cmt.Get("UNSYNCEDLYRICS")
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
return lyrics[0], nil
@@ -398,16 +447,12 @@ func ExtractLyrics(filePath string) (string, error) {
return "", fmt.Errorf("no lyrics found in file")
}
// AudioQuality represents audio quality info from a FLAC file
type AudioQuality struct {
BitDepth int `json:"bit_depth"`
SampleRate int `json:"sample_rate"`
TotalSamples int64 `json:"total_samples"`
}
// GetAudioQuality reads bit depth and sample rate from a FLAC file's StreamInfo block
// FLAC StreamInfo is always the first metadata block after the 4-byte "fLaC" marker
// For M4A files, it delegates to GetM4AQuality
func GetAudioQuality(filePath string) (AudioQuality, error) {
file, err := os.Open(filePath)
if err != nil {
@@ -415,16 +460,12 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
}
defer file.Close()
// Read first 4 bytes to detect file type
marker := make([]byte, 4)
if _, err := file.Read(marker); err != nil {
return AudioQuality{}, fmt.Errorf("failed to read marker: %w", err)
}
// Check if it's a FLAC file
if string(marker) == "fLaC" {
// Continue reading FLAC metadata
// Read metadata block header (4 bytes)
header := make([]byte, 4)
if _, err := file.Read(header); err != nil {
return AudioQuality{}, fmt.Errorf("failed to read header: %w", err)
@@ -435,19 +476,15 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
return AudioQuality{}, fmt.Errorf("first block is not STREAMINFO")
}
// Read STREAMINFO block (34 bytes minimum)
streamInfo := make([]byte, 34)
if _, err := file.Read(streamInfo); err != nil {
return AudioQuality{}, fmt.Errorf("failed to read STREAMINFO: %w", err)
}
// Parse sample rate (20 bits starting at byte 10)
sampleRate := (int(streamInfo[10]) << 12) | (int(streamInfo[11]) << 4) | (int(streamInfo[12]) >> 4)
// Parse bits per sample (5 bits)
bitsPerSample := ((int(streamInfo[12]) & 0x01) << 4) | (int(streamInfo[13]) >> 4) + 1
// Parse total samples (36 bits: 4 bits from byte 13, all of bytes 14-17)
totalSamples := int64(streamInfo[13]&0x0F)<<32 |
int64(streamInfo[14])<<24 |
int64(streamInfo[15])<<16 |
@@ -461,17 +498,14 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
}, nil
}
// Check if it's an M4A/MP4 file (starts with size + "ftyp")
// First 4 bytes are size, next 4 should be "ftyp"
file.Seek(0, 0) // Reset to beginning
file.Seek(0, 0)
header8 := make([]byte, 8)
if _, err := file.Read(header8); err != nil {
return AudioQuality{}, fmt.Errorf("failed to read header: %w", err)
}
if string(header8[4:8]) == "ftyp" {
// It's an M4A/MP4 file, use M4A quality reader
file.Close() // Close before calling GetM4AQuality which opens the file again
file.Close()
return GetM4AQuality(filePath)
}
@@ -483,91 +517,171 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
// ========================================
// EmbedM4AMetadata embeds metadata into an M4A file using iTunes-style atoms
// This is a simplified implementation that writes metadata to the file
func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) error {
// Read the entire file
data, err := os.ReadFile(filePath)
input, err := os.Open(filePath)
if err != nil {
return fmt.Errorf("failed to read M4A file: %w", err)
return fmt.Errorf("failed to open M4A file: %w", err)
}
defer input.Close()
// Find moov atom position
moovPos := findAtom(data, "moov", 0)
if moovPos < 0 {
info, err := input.Stat()
if err != nil {
return fmt.Errorf("failed to stat M4A file: %w", err)
}
fileSize := info.Size()
moovHeader, moovFound, err := findAtomInRange(input, 0, fileSize, "moov", fileSize)
if err != nil {
return fmt.Errorf("failed to find moov atom: %w", err)
}
if !moovFound {
return fmt.Errorf("moov atom not found in M4A file")
}
// Find udta atom inside moov, or create one
moovSize := int(uint32(data[moovPos])<<24 | uint32(data[moovPos+1])<<16 | uint32(data[moovPos+2])<<8 | uint32(data[moovPos+3]))
udtaPos := findAtom(data, "udta", moovPos+8)
moovContentStart := moovHeader.offset + moovHeader.headerSize
moovContentSize := moovHeader.size - moovHeader.headerSize
// Build new metadata atoms
metaAtom := buildMetaAtom(metadata, coverData)
udtaHeader, udtaFound, err := findAtomInRange(input, moovContentStart, moovContentSize, "udta", fileSize)
if err != nil {
return fmt.Errorf("failed to locate udta atom: %w", err)
}
var newData []byte
if udtaPos >= 0 && udtaPos < moovPos+moovSize {
// udta exists, find meta inside it or replace
udtaSize := int(uint32(data[udtaPos])<<24 | uint32(data[udtaPos+1])<<16 | uint32(data[udtaPos+2])<<8 | uint32(data[udtaPos+3]))
metaPos := findAtom(data, "meta", udtaPos+8)
if metaPos >= 0 && metaPos < udtaPos+udtaSize {
// Replace existing meta atom
metaSize := int(uint32(data[metaPos])<<24 | uint32(data[metaPos+1])<<16 | uint32(data[metaPos+2])<<8 | uint32(data[metaPos+3]))
newData = append(newData, data[:metaPos]...)
newData = append(newData, metaAtom...)
newData = append(newData, data[metaPos+metaSize:]...)
} else {
// Add meta atom to udta
newUdtaContent := append(data[udtaPos+8:udtaPos+udtaSize], metaAtom...)
newUdtaSize := 8 + len(newUdtaContent)
newUdta := make([]byte, 4)
newUdta[0] = byte(newUdtaSize >> 24)
newUdta[1] = byte(newUdtaSize >> 16)
newUdta[2] = byte(newUdtaSize >> 8)
newUdta[3] = byte(newUdtaSize)
newUdta = append(newUdta, []byte("udta")...)
newUdta = append(newUdta, newUdtaContent...)
newData = append(newData, data[:udtaPos]...)
newData = append(newData, newUdta...)
newData = append(newData, data[udtaPos+udtaSize:]...)
var metaHeader atomHeader
metaFound := false
if udtaFound {
udtaContentStart := udtaHeader.offset + udtaHeader.headerSize
udtaContentSize := udtaHeader.size - udtaHeader.headerSize
metaHeader, metaFound, err = findAtomInRange(input, udtaContentStart, udtaContentSize, "meta", fileSize)
if err != nil {
return fmt.Errorf("failed to locate meta atom: %w", err)
}
} else {
// Create new udta with meta
udtaContent := metaAtom
udtaSize := 8 + len(udtaContent)
newUdta := make([]byte, 4)
newUdta[0] = byte(udtaSize >> 24)
newUdta[1] = byte(udtaSize >> 16)
newUdta[2] = byte(udtaSize >> 8)
newUdta[3] = byte(udtaSize)
newUdta = append(newUdta, []byte("udta")...)
newUdta = append(newUdta, udtaContent...)
// Insert udta at end of moov
insertPos := moovPos + moovSize
newData = append(newData, data[:insertPos]...)
newData = append(newData, newUdta...)
newData = append(newData, data[insertPos:]...)
}
// Update moov size
newMoovSize := moovSize + len(newData) - len(data)
newData[moovPos] = byte(newMoovSize >> 24)
newData[moovPos+1] = byte(newMoovSize >> 16)
newData[moovPos+2] = byte(newMoovSize >> 8)
newData[moovPos+3] = byte(newMoovSize)
metaAtom := buildMetaAtom(metadata, coverData)
metaSize := int64(len(metaAtom))
// Write back to file
if err := os.WriteFile(filePath, newData, 0644); err != nil {
return fmt.Errorf("failed to write M4A file: %w", err)
var delta int64
var newUdtaSize int64
switch {
case udtaFound && metaFound:
delta = metaSize - metaHeader.size
newUdtaSize = udtaHeader.size + delta
case udtaFound && !metaFound:
delta = metaSize
newUdtaSize = udtaHeader.size + delta
case !udtaFound:
newUdtaSize = int64(8 + len(metaAtom))
delta = newUdtaSize
}
newMoovSize := moovHeader.size + delta
if moovHeader.headerSize == 8 && newMoovSize > int64(^uint32(0)) {
return fmt.Errorf("moov atom exceeds 32-bit size after update")
}
if udtaFound && udtaHeader.headerSize == 8 && newUdtaSize > int64(^uint32(0)) {
return fmt.Errorf("udta atom exceeds 32-bit size after update")
}
if !udtaFound && newUdtaSize > int64(^uint32(0)) {
return fmt.Errorf("udta atom exceeds 32-bit size after update")
}
tempPath := filePath + ".tmp"
output, err := os.OpenFile(tempPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644)
if err != nil {
return fmt.Errorf("failed to create temp file: %w", err)
}
cleanupTemp := true
defer func() {
_ = output.Close()
if cleanupTemp {
_ = os.Remove(tempPath)
}
}()
switch {
case udtaFound && metaFound:
if err := copyRange(output, input, 0, moovHeader.offset); err != nil {
return err
}
if err := writeAtomHeader(output, "moov", newMoovSize, moovHeader.headerSize); err != nil {
return err
}
if err := copyRange(output, input, moovHeader.offset+moovHeader.headerSize, udtaHeader.offset-(moovHeader.offset+moovHeader.headerSize)); err != nil {
return err
}
if err := writeAtomHeader(output, "udta", newUdtaSize, udtaHeader.headerSize); err != nil {
return err
}
if err := copyRange(output, input, udtaHeader.offset+udtaHeader.headerSize, metaHeader.offset-(udtaHeader.offset+udtaHeader.headerSize)); err != nil {
return err
}
if _, err := output.Write(metaAtom); err != nil {
return fmt.Errorf("failed to write meta atom: %w", err)
}
metaEnd := metaHeader.offset + metaHeader.size
if err := copyRange(output, input, metaEnd, fileSize-metaEnd); err != nil {
return err
}
case udtaFound && !metaFound:
if err := copyRange(output, input, 0, moovHeader.offset); err != nil {
return err
}
if err := writeAtomHeader(output, "moov", newMoovSize, moovHeader.headerSize); err != nil {
return err
}
if err := copyRange(output, input, moovHeader.offset+moovHeader.headerSize, udtaHeader.offset-(moovHeader.offset+moovHeader.headerSize)); err != nil {
return err
}
if err := writeAtomHeader(output, "udta", newUdtaSize, udtaHeader.headerSize); err != nil {
return err
}
insertPos := udtaHeader.offset + udtaHeader.size
if err := copyRange(output, input, udtaHeader.offset+udtaHeader.headerSize, insertPos-(udtaHeader.offset+udtaHeader.headerSize)); err != nil {
return err
}
if _, err := output.Write(metaAtom); err != nil {
return fmt.Errorf("failed to write meta atom: %w", err)
}
if err := copyRange(output, input, insertPos, fileSize-insertPos); err != nil {
return err
}
case !udtaFound:
newUdtaAtom := buildUdtaAtom(metaAtom)
if err := copyRange(output, input, 0, moovHeader.offset); err != nil {
return err
}
if err := writeAtomHeader(output, "moov", newMoovSize, moovHeader.headerSize); err != nil {
return err
}
moovEnd := moovHeader.offset + moovHeader.size
if err := copyRange(output, input, moovHeader.offset+moovHeader.headerSize, moovEnd-(moovHeader.offset+moovHeader.headerSize)); err != nil {
return err
}
if _, err := output.Write(newUdtaAtom); err != nil {
return fmt.Errorf("failed to write udta atom: %w", err)
}
if err := copyRange(output, input, moovEnd, fileSize-moovEnd); err != nil {
return err
}
}
if err := output.Close(); err != nil {
return fmt.Errorf("failed to close temp file: %w", err)
}
_ = input.Close()
if err := os.Remove(filePath); err != nil {
return fmt.Errorf("failed to replace original file: %w", err)
}
if err := os.Rename(tempPath, filePath); err != nil {
return fmt.Errorf("failed to move temp file: %w", err)
}
cleanupTemp = false
fmt.Printf("[M4A] Metadata embedded successfully\n")
return nil
}
// findAtom finds an atom by name starting from offset
func findAtom(data []byte, name string, offset int) int {
for i := offset; i < len(data)-8; {
size := int(uint32(data[i])<<24 | uint32(data[i+1])<<16 | uint32(data[i+2])<<8 | uint32(data[i+3]))
@@ -585,55 +699,44 @@ func findAtom(data []byte, name string, offset int) int {
// buildMetaAtom builds a complete meta atom with ilst containing metadata
func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
// Build ilst content
var ilst []byte
// ©nam - Title
if metadata.Title != "" {
ilst = append(ilst, buildTextAtom("©nam", metadata.Title)...)
}
// ©ART - Artist
if metadata.Artist != "" {
ilst = append(ilst, buildTextAtom("©ART", metadata.Artist)...)
}
// ©alb - Album
if metadata.Album != "" {
ilst = append(ilst, buildTextAtom("©alb", metadata.Album)...)
}
// aART - Album Artist
if metadata.AlbumArtist != "" {
ilst = append(ilst, buildTextAtom("aART", metadata.AlbumArtist)...)
}
// ©day - Year/Date
if metadata.Date != "" {
ilst = append(ilst, buildTextAtom("©day", metadata.Date)...)
}
// trkn - Track Number
if metadata.TrackNumber > 0 {
ilst = append(ilst, buildTrackNumberAtom(metadata.TrackNumber, metadata.TotalTracks)...)
}
// disk - Disc Number
if metadata.DiscNumber > 0 {
ilst = append(ilst, buildDiscNumberAtom(metadata.DiscNumber, 0)...)
}
// ©lyr - Lyrics
if metadata.Lyrics != "" {
ilst = append(ilst, buildTextAtom("©lyr", metadata.Lyrics)...)
}
// covr - Cover Art
if len(coverData) > 0 {
ilst = append(ilst, buildCoverAtom(coverData)...)
}
// Build ilst atom
ilstSize := 8 + len(ilst)
ilstAtom := make([]byte, 4)
ilstAtom[0] = byte(ilstSize >> 24)
@@ -643,7 +746,6 @@ func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
ilstAtom = append(ilstAtom, []byte("ilst")...)
ilstAtom = append(ilstAtom, ilst...)
// Build hdlr atom (required for meta)
hdlr := []byte{
0, 0, 0, 33, // size = 33
'h', 'd', 'l', 'r',
@@ -656,7 +758,6 @@ func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
0, // null terminator
}
// Build meta atom
metaContent := append([]byte{0, 0, 0, 0}, hdlr...) // version + flags + hdlr
metaContent = append(metaContent, ilstAtom...)
@@ -672,11 +773,9 @@ func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
return metaAtom
}
// buildTextAtom builds a text metadata atom (©nam, ©ART, etc.)
func buildTextAtom(name, value string) []byte {
valueBytes := []byte(value)
// data atom
dataSize := 16 + len(valueBytes)
dataAtom := make([]byte, 4)
dataAtom[0] = byte(dataSize >> 24)
@@ -688,7 +787,6 @@ func buildTextAtom(name, value string) []byte {
dataAtom = append(dataAtom, 0, 0, 0, 0) // locale
dataAtom = append(dataAtom, valueBytes...)
// container atom
atomSize := 8 + len(dataAtom)
atom := make([]byte, 4)
atom[0] = byte(atomSize >> 24)
@@ -703,7 +801,6 @@ func buildTextAtom(name, value string) []byte {
// buildTrackNumberAtom builds trkn atom
func buildTrackNumberAtom(track, total int) []byte {
// data atom with track number
dataAtom := []byte{
0, 0, 0, 24, // size
'd', 'a', 't', 'a',
@@ -715,7 +812,6 @@ func buildTrackNumberAtom(track, total int) []byte {
0, 0, // padding
}
// trkn atom
atomSize := 8 + len(dataAtom)
atom := make([]byte, 4)
atom[0] = byte(atomSize >> 24)
@@ -728,9 +824,7 @@ func buildTrackNumberAtom(track, total int) []byte {
return atom
}
// buildDiscNumberAtom builds disk atom
func buildDiscNumberAtom(disc, total int) []byte {
// data atom with disc number
dataAtom := []byte{
0, 0, 0, 22, // size
'd', 'a', 't', 'a',
@@ -741,7 +835,6 @@ func buildDiscNumberAtom(disc, total int) []byte {
byte(total >> 8), byte(total), // total discs
}
// disk atom
atomSize := 8 + len(dataAtom)
atom := make([]byte, 4)
atom[0] = byte(atomSize >> 24)
@@ -756,13 +849,11 @@ func buildDiscNumberAtom(disc, total int) []byte {
// buildCoverAtom builds covr atom with image data
func buildCoverAtom(coverData []byte) []byte {
// Detect image type (JPEG = 13, PNG = 14)
imageType := byte(13) // default JPEG
imageType := byte(13)
if len(coverData) > 8 && coverData[0] == 0x89 && coverData[1] == 'P' && coverData[2] == 'N' && coverData[3] == 'G' {
imageType = 14 // PNG
imageType = 14
}
// data atom
dataSize := 16 + len(coverData)
dataAtom := make([]byte, 4)
dataAtom[0] = byte(dataSize >> 24)
@@ -770,11 +861,10 @@ func buildCoverAtom(coverData []byte) []byte {
dataAtom[2] = byte(dataSize >> 8)
dataAtom[3] = byte(dataSize)
dataAtom = append(dataAtom, []byte("data")...)
dataAtom = append(dataAtom, 0, 0, 0, imageType) // type = JPEG or PNG
dataAtom = append(dataAtom, 0, 0, 0, 0) // locale
dataAtom = append(dataAtom, 0, 0, 0, imageType)
dataAtom = append(dataAtom, 0, 0, 0, 0)
dataAtom = append(dataAtom, coverData...)
// covr atom
atomSize := 8 + len(dataAtom)
atom := make([]byte, 4)
atom[0] = byte(atomSize >> 24)
@@ -787,36 +877,226 @@ func buildCoverAtom(coverData []byte) []byte {
return atom
}
// GetM4AQuality reads audio quality from M4A file
func GetM4AQuality(filePath string) (AudioQuality, error) {
data, err := os.ReadFile(filePath)
f, err := os.Open(filePath)
if err != nil {
return AudioQuality{}, fmt.Errorf("failed to read M4A file: %w", err)
return AudioQuality{}, fmt.Errorf("failed to open M4A file: %w", err)
}
defer f.Close()
// Find moov -> trak -> mdia -> minf -> stbl -> stsd
moovPos := findAtom(data, "moov", 0)
if moovPos < 0 {
info, err := f.Stat()
if err != nil {
return AudioQuality{}, fmt.Errorf("failed to stat M4A file: %w", err)
}
fileSize := info.Size()
moovHeader, moovFound, err := findAtomInRange(f, 0, fileSize, "moov", fileSize)
if err != nil {
return AudioQuality{}, fmt.Errorf("failed to find moov atom: %w", err)
}
if !moovFound {
return AudioQuality{}, fmt.Errorf("moov atom not found")
}
// Search for mp4a or alac atom which contains audio info
// This is a simplified search - real implementation would traverse the atom tree
for i := moovPos; i < len(data)-20; i++ {
if string(data[i:i+4]) == "mp4a" || string(data[i:i+4]) == "alac" {
// Sample rate is at offset 22-23 from atom start (16-bit big-endian)
if i+24 < len(data) {
sampleRate := int(data[i+22])<<8 | int(data[i+23])
// For AAC, bit depth is typically 16
bitDepth := 16
if string(data[i:i+4]) == "alac" {
// ALAC can have higher bit depth, check esds or alac specific data
bitDepth = 24 // Assume 24-bit for ALAC
}
return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate}, nil
}
}
moovStart := moovHeader.offset
moovEnd := moovHeader.offset + moovHeader.size
sampleOffset, atomType, err := findAudioSampleEntry(f, moovStart, moovEnd, fileSize)
if err != nil {
return AudioQuality{}, err
}
return AudioQuality{}, fmt.Errorf("audio info not found in M4A file")
buf := make([]byte, 24)
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
}
return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate}, nil
}
type atomHeader struct {
offset int64
size int64
headerSize int64
typ string
}
func readAtomHeaderAt(f *os.File, offset, fileSize int64) (atomHeader, error) {
if offset+8 > fileSize {
return atomHeader{}, io.ErrUnexpectedEOF
}
headerBuf := make([]byte, 8)
if _, err := f.ReadAt(headerBuf, offset); err != nil {
return atomHeader{}, err
}
size32 := binary.BigEndian.Uint32(headerBuf[0:4])
typ := string(headerBuf[4:8])
if size32 == 1 {
if offset+16 > fileSize {
return atomHeader{}, io.ErrUnexpectedEOF
}
extBuf := make([]byte, 8)
if _, err := f.ReadAt(extBuf, offset+8); err != nil {
return atomHeader{}, err
}
size64 := binary.BigEndian.Uint64(extBuf)
return atomHeader{offset: offset, size: int64(size64), headerSize: 16, typ: typ}, nil
}
return atomHeader{offset: offset, size: int64(size32), headerSize: 8, typ: typ}, nil
}
func findAtomInRange(f *os.File, start, size int64, target string, fileSize int64) (atomHeader, bool, error) {
if size <= 0 {
return atomHeader{}, false, nil
}
end := start + size
pos := start
for pos+8 <= end {
header, err := readAtomHeaderAt(f, pos, fileSize)
if err != nil {
return atomHeader{}, false, err
}
atomSize := header.size
if atomSize == 0 {
atomSize = end - pos
}
if atomSize < header.headerSize {
return atomHeader{}, false, fmt.Errorf("invalid atom size for %s", header.typ)
}
header.size = atomSize
if header.typ == target {
return header, true, nil
}
pos += atomSize
}
return atomHeader{}, false, nil
}
func writeAtomHeader(w io.Writer, typ string, size int64, headerSize int64) error {
if len(typ) != 4 {
return fmt.Errorf("invalid atom type: %s", typ)
}
if headerSize == 16 {
header := make([]byte, 16)
binary.BigEndian.PutUint32(header[0:4], 1)
copy(header[4:8], []byte(typ))
binary.BigEndian.PutUint64(header[8:16], uint64(size))
_, err := w.Write(header)
return err
}
if size > int64(^uint32(0)) {
return fmt.Errorf("atom size exceeds 32-bit for %s", typ)
}
header := make([]byte, 8)
binary.BigEndian.PutUint32(header[0:4], uint32(size))
copy(header[4:8], []byte(typ))
_, err := w.Write(header)
return err
}
func copyRange(dst io.Writer, src *os.File, offset, length int64) error {
if length <= 0 {
return nil
}
if _, err := src.Seek(offset, io.SeekStart); err != nil {
return fmt.Errorf("failed to seek source: %w", err)
}
if _, err := io.CopyN(dst, src, length); err != nil {
return fmt.Errorf("failed to copy data: %w", err)
}
return nil
}
func buildUdtaAtom(metaAtom []byte) []byte {
size := 8 + len(metaAtom)
header := make([]byte, 8)
binary.BigEndian.PutUint32(header[0:4], uint32(size))
copy(header[4:8], []byte("udta"))
return append(header, metaAtom...)
}
func findAudioSampleEntry(f *os.File, start, end, fileSize int64) (int64, string, error) {
const chunkSize = 64 * 1024
patternMP4A := []byte("mp4a")
patternALAC := []byte("alac")
var tail []byte
readPos := start
for readPos < end {
toRead := end - readPos
if toRead > chunkSize {
toRead = chunkSize
}
buf := make([]byte, toRead)
n, err := f.ReadAt(buf, readPos)
if err != nil && err != io.EOF {
return 0, "", fmt.Errorf("failed to read M4A atom data: %w", err)
}
if n == 0 {
break
}
data := append(tail, buf[:n]...)
mp4aIdx := bytes.Index(data, patternMP4A)
alacIdx := bytes.Index(data, patternALAC)
bestIdx := -1
bestType := ""
switch {
case mp4aIdx >= 0 && alacIdx >= 0:
if mp4aIdx <= alacIdx {
bestIdx = mp4aIdx
bestType = "mp4a"
} else {
bestIdx = alacIdx
bestType = "alac"
}
case mp4aIdx >= 0:
bestIdx = mp4aIdx
bestType = "mp4a"
case alacIdx >= 0:
bestIdx = alacIdx
bestType = "alac"
}
if bestIdx >= 0 {
absolute := readPos - int64(len(tail)) + int64(bestIdx)
if absolute+24 > fileSize {
return 0, "", fmt.Errorf("audio info not found in M4A file")
}
return absolute, bestType, nil
}
if len(data) >= 3 {
tail = append([]byte{}, data[len(data)-3:]...)
} else {
tail = append([]byte{}, data...)
}
readPos += int64(n)
}
return 0, "", fmt.Errorf("audio info not found in M4A file")
}
+8 -49
View File
@@ -6,11 +6,6 @@ import (
"time"
)
// ========================================
// ISRC to Track ID Cache
// ========================================
// TrackIDCacheEntry holds cached track ID with metadata
type TrackIDCacheEntry struct {
TidalTrackID int64
QobuzTrackID int64
@@ -18,7 +13,6 @@ type TrackIDCacheEntry struct {
ExpiresAt time.Time
}
// TrackIDCache caches ISRC to track ID mappings
type TrackIDCache struct {
cache map[string]*TrackIDCacheEntry
mu sync.RWMutex
@@ -30,18 +24,16 @@ var (
trackIDCacheOnce sync.Once
)
// GetTrackIDCache returns the global track ID cache
func GetTrackIDCache() *TrackIDCache {
trackIDCacheOnce.Do(func() {
globalTrackIDCache = &TrackIDCache{
cache: make(map[string]*TrackIDCacheEntry),
ttl: 30 * time.Minute, // Cache for 30 minutes
ttl: 30 * time.Minute,
}
})
return globalTrackIDCache
}
// Get retrieves a cached entry by ISRC
func (c *TrackIDCache) Get(isrc string) *TrackIDCacheEntry {
c.mu.RLock()
defer c.mu.RUnlock()
@@ -53,7 +45,6 @@ func (c *TrackIDCache) Get(isrc string) *TrackIDCacheEntry {
return entry
}
// SetTidal caches Tidal track ID for an ISRC
func (c *TrackIDCache) SetTidal(isrc string, trackID int64) {
c.mu.Lock()
defer c.mu.Unlock()
@@ -67,7 +58,6 @@ func (c *TrackIDCache) SetTidal(isrc string, trackID int64) {
entry.ExpiresAt = time.Now().Add(c.ttl)
}
// SetQobuz caches Qobuz track ID for an ISRC
func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) {
c.mu.Lock()
defer c.mu.Unlock()
@@ -81,7 +71,6 @@ func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) {
entry.ExpiresAt = time.Now().Add(c.ttl)
}
// SetAmazon caches Amazon track ID for an ISRC
func (c *TrackIDCache) SetAmazon(isrc string, trackID string) {
c.mu.Lock()
defer c.mu.Unlock()
@@ -95,24 +84,18 @@ func (c *TrackIDCache) SetAmazon(isrc string, trackID string) {
entry.ExpiresAt = time.Now().Add(c.ttl)
}
// Clear removes all cached entries
func (c *TrackIDCache) Clear() {
c.mu.Lock()
defer c.mu.Unlock()
c.cache = make(map[string]*TrackIDCacheEntry)
}
// Size returns the number of cached entries
func (c *TrackIDCache) Size() int {
c.mu.RLock()
defer c.mu.RUnlock()
return len(c.cache)
}
// ========================================
// Parallel Download Helper
// ========================================
// ParallelDownloadResult holds results from parallel operations
type ParallelDownloadResult struct {
CoverData []byte
@@ -122,8 +105,6 @@ type ParallelDownloadResult struct {
LyricsErr error
}
// FetchCoverAndLyricsParallel downloads cover and fetches lyrics in parallel
// This runs while the main audio download is happening
func FetchCoverAndLyricsParallel(
coverURL string,
maxQualityCover bool,
@@ -131,11 +112,11 @@ func FetchCoverAndLyricsParallel(
trackName string,
artistName string,
embedLyrics bool,
durationMs int64,
) *ParallelDownloadResult {
result := &ParallelDownloadResult{}
var wg sync.WaitGroup
// Download cover in parallel
if coverURL != "" {
wg.Add(1)
go func() {
@@ -152,20 +133,19 @@ func FetchCoverAndLyricsParallel(
}()
}
// Fetch lyrics in parallel
if embedLyrics {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("[Parallel] Starting lyrics fetch...")
client := NewLyricsClient()
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName)
durationSec := float64(durationMs) / 1000.0
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
if err != nil {
result.LyricsErr = err
fmt.Printf("[Parallel] Lyrics fetch failed: %v\n", err)
} else if lyrics != nil && len(lyrics.Lines) > 0 {
result.LyricsData = lyrics
// Use LRC with metadata headers (like PC version)
result.LyricsLRC = convertToLRCWithMetadata(lyrics, trackName, artistName)
fmt.Printf("[Parallel] Lyrics fetched: %d lines\n", len(lyrics.Lines))
} else {
@@ -179,11 +159,6 @@ func FetchCoverAndLyricsParallel(
return result
}
// ========================================
// Pre-warm Cache for Album/Playlist
// ========================================
// PreWarmCacheRequest represents a track to pre-warm cache for
type PreWarmCacheRequest struct {
ISRC string
TrackName string
@@ -192,8 +167,6 @@ type PreWarmCacheRequest struct {
Service string // "tidal", "qobuz", "amazon"
}
// PreWarmTrackCache pre-fetches track IDs for multiple tracks (for album/playlist)
// This runs in background while user is viewing the track list
func PreWarmTrackCache(requests []PreWarmCacheRequest) {
if len(requests) == 0 {
return
@@ -202,12 +175,10 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) {
fmt.Printf("[Cache] Pre-warming cache for %d tracks...\n", len(requests))
cache := GetTrackIDCache()
// Limit concurrent pre-warm requests
semaphore := make(chan struct{}, 3) // Max 3 concurrent
semaphore := make(chan struct{}, 3)
var wg sync.WaitGroup
for _, req := range requests {
// Skip if already cached
if cached := cache.Get(req.ISRC); cached != nil {
continue
}
@@ -215,8 +186,8 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) {
wg.Add(1)
go func(r PreWarmCacheRequest) {
defer wg.Done()
semaphore <- struct{}{} // Acquire
defer func() { <-semaphore }() // Release
semaphore <- struct{}{}
defer func() { <-semaphore }()
switch r.Service {
case "tidal":
@@ -252,38 +223,26 @@ func preWarmQobuzCache(isrc string) {
}
func preWarmAmazonCache(isrc, spotifyID string) {
// Amazon uses SongLink to get URL, so we pre-warm by checking availability
client := NewSongLinkClient()
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
if err == nil && availability != nil && availability.Amazon {
// Store Amazon URL in cache (using ISRC as key)
GetTrackIDCache().SetAmazon(isrc, availability.AmazonURL)
fmt.Printf("[Cache] Cached Amazon URL for ISRC %s\n", isrc)
}
}
// ========================================
// Exported Functions for Flutter
// ========================================
// PreWarmCache is called from Flutter to pre-warm cache for album/playlist tracks
// tracksJSON is a JSON array of {isrc, track_name, artist_name, service}
func PreWarmCache(tracksJSON string) error {
var requests []PreWarmCacheRequest
// Parse JSON (simplified - in production use proper JSON parsing)
// For now, this is called from exports.go with proper parsing
go PreWarmTrackCache(requests) // Run in background
go PreWarmTrackCache(requests)
return nil
}
// ClearTrackCache clears the track ID cache
func ClearTrackCache() {
GetTrackIDCache().Clear()
fmt.Println("[Cache] Track ID cache cleared")
}
// GetCacheSize returns the current cache size
func GetCacheSize() int {
return GetTrackIDCache().Size()
}
+5 -26
View File
@@ -6,8 +6,6 @@ import (
"time"
)
// DownloadProgress represents current download progress
// Now unified - returns data from multi-progress system
type DownloadProgress struct {
CurrentFile string `json:"current_file"`
Progress float64 `json:"progress"`
@@ -15,21 +13,19 @@ type DownloadProgress struct {
BytesTotal int64 `json:"bytes_total"`
BytesReceived int64 `json:"bytes_received"`
IsDownloading bool `json:"is_downloading"`
Status string `json:"status"` // "downloading", "finalizing", "completed"
Status string `json:"status"`
}
// ItemProgress represents progress for a single download item
type ItemProgress struct {
ItemID string `json:"item_id"`
BytesTotal int64 `json:"bytes_total"`
BytesReceived int64 `json:"bytes_received"`
Progress float64 `json:"progress"` // 0.0 to 1.0
SpeedMBps float64 `json:"speed_mbps"` // Download speed in MB/s
Progress float64 `json:"progress"`
SpeedMBps float64 `json:"speed_mbps"`
IsDownloading bool `json:"is_downloading"`
Status string `json:"status"` // "downloading", "finalizing", "completed"
Status string `json:"status"`
}
// MultiProgress holds progress for multiple concurrent downloads
type MultiProgress struct {
Items map[string]*ItemProgress `json:"items"`
}
@@ -38,22 +34,18 @@ var (
downloadDir string
downloadDirMu sync.RWMutex
// Multi-download progress tracking (unified system)
multiProgress = MultiProgress{Items: make(map[string]*ItemProgress)}
multiMu sync.RWMutex
)
// getProgress returns current download progress from multi-progress system
// Returns first active item's progress for backward compatibility
func getProgress() DownloadProgress {
multiMu.RLock()
defer multiMu.RUnlock()
// Find first active item
for _, item := range multiProgress.Items {
return DownloadProgress{
CurrentFile: item.ItemID,
Progress: item.Progress * 100, // Convert to percentage
Progress: item.Progress * 100,
BytesTotal: item.BytesTotal,
BytesReceived: item.BytesReceived,
IsDownloading: item.IsDownloading,
@@ -64,7 +56,6 @@ func getProgress() DownloadProgress {
return DownloadProgress{}
}
// GetMultiProgress returns progress for all active downloads as JSON
func GetMultiProgress() string {
multiMu.RLock()
defer multiMu.RUnlock()
@@ -76,7 +67,6 @@ func GetMultiProgress() string {
return string(jsonBytes)
}
// GetItemProgress returns progress for a specific item as JSON
func GetItemProgress(itemID string) string {
multiMu.RLock()
defer multiMu.RUnlock()
@@ -203,14 +193,6 @@ func setDownloadDir(path string) error {
return nil
}
// getDownloadDir returns the default download directory
// Kept for potential future use
// func getDownloadDir() string {
// downloadDirMu.RLock()
// defer downloadDirMu.RUnlock()
// return downloadDir
// }
// ItemProgressWriter wraps io.Writer to track download progress for a specific item
type ItemProgressWriter struct {
writer interface{ Write([]byte) (int, error) }
@@ -249,10 +231,7 @@ func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
}
pw.current += int64(n)
// Update progress when we've received at least 64KB since last update
// Also update on first write to show download has started
if pw.lastReported == 0 || pw.current-pw.lastReported >= progressUpdateThreshold {
// Calculate speed (MB/s) based on bytes received since last update
now := time.Now()
elapsed := now.Sub(pw.lastTime).Seconds()
var speedMBps float64
+35 -75
View File
@@ -1,8 +1,8 @@
package gobackend
import (
"context"
"bufio"
"context"
"encoding/base64"
"encoding/json"
"errors"
@@ -17,7 +17,6 @@ import (
"time"
)
// QobuzDownloader handles Qobuz downloads
type QobuzDownloader struct {
client *http.Client
appID string
@@ -25,12 +24,10 @@ type QobuzDownloader struct {
}
var (
// Global Qobuz downloader instance for connection reuse
globalQobuzDownloader *QobuzDownloader
qobuzDownloaderOnce sync.Once
)
// QobuzTrack represents a Qobuz track
type QobuzTrack struct {
ID int64 `json:"id"`
Title string `json:"title"`
@@ -51,7 +48,6 @@ type QobuzTrack struct {
} `json:"performer"`
}
// qobuzArtistsMatch checks if the artist names are similar enough
func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
@@ -66,22 +62,17 @@ func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
return true
}
// Split expected artists by common separators (comma, feat, ft., &, and)
// e.g., "RADWIMPS, Toko Miura" or "RADWIMPS feat. Toko Miura"
expectedArtists := qobuzSplitArtists(normExpected)
foundArtists := qobuzSplitArtists(normFound)
// Check if ANY expected artist matches ANY found artist
for _, exp := range expectedArtists {
for _, fnd := range foundArtists {
if exp == fnd {
return true
}
// Also check contains for partial matches
if strings.Contains(exp, fnd) || strings.Contains(fnd, exp) {
return true
}
// Check same words different order
if qobuzSameWordsUnordered(exp, fnd) {
GoLog("[Qobuz] Artist names have same words in different order: '%s' vs '%s'\n", exp, fnd)
return true
@@ -89,8 +80,6 @@ func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
}
}
// If scripts are TRULY different (Latin vs CJK/Arabic/Cyrillic), assume match (transliteration)
// Don't treat Latin Extended (Polish, French, etc.) as different script
expectedLatin := qobuzIsLatinScript(expectedArtist)
foundLatin := qobuzIsLatinScript(foundArtist)
if expectedLatin != foundLatin {
@@ -101,9 +90,7 @@ func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
return false
}
// qobuzSplitArtists splits artist string by common separators
func qobuzSplitArtists(artists string) []string {
// Replace common separators with a standard one
normalized := artists
normalized = strings.ReplaceAll(normalized, " feat. ", "|")
normalized = strings.ReplaceAll(normalized, " feat ", "|")
@@ -162,7 +149,6 @@ func qobuzSameWordsUnordered(a, b string) bool {
return true
}
// qobuzTitlesMatch checks if track titles are similar enough
func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
normExpected := strings.ToLower(strings.TrimSpace(expectedTitle))
normFound := strings.ToLower(strings.TrimSpace(foundTitle))
@@ -172,12 +158,10 @@ func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
return true
}
// Check if one contains the other
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
return true
}
// Clean BOTH titles and compare (removes suffixes like remaster, remix, etc)
cleanExpected := qobuzCleanTitle(normExpected)
cleanFound := qobuzCleanTitle(normFound)
@@ -185,14 +169,12 @@ func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
return true
}
// Check if cleaned versions contain each other
if cleanExpected != "" && cleanFound != "" {
if strings.Contains(cleanExpected, cleanFound) || strings.Contains(cleanFound, cleanExpected) {
return true
}
}
// Extract core title (before any parentheses/brackets)
coreExpected := qobuzExtractCoreTitle(normExpected)
coreFound := qobuzExtractCoreTitle(normFound)
@@ -233,19 +215,15 @@ func qobuzExtractCoreTitle(title string) string {
return strings.TrimSpace(title[:cutIdx])
}
// qobuzCleanTitle removes common suffixes from track titles for comparison
func qobuzCleanTitle(title string) string {
cleaned := title
// Remove content in parentheses/brackets that are version indicators
// This helps match "Song (Remastered)" with "Song" or "Song (2024 Remaster)"
versionPatterns := []string{
"remaster", "remastered", "deluxe", "bonus", "single",
"album version", "radio edit", "original mix", "extended",
"club mix", "remix", "live", "acoustic", "demo",
}
// Remove parenthetical content if it contains version indicators
for {
startParen := strings.LastIndex(cleaned, "(")
endParen := strings.LastIndex(cleaned, ")")
@@ -266,7 +244,6 @@ func qobuzCleanTitle(title string) string {
break
}
// Same for brackets
for {
startBracket := strings.LastIndex(cleaned, "[")
endBracket := strings.LastIndex(cleaned, "]")
@@ -287,7 +264,6 @@ func qobuzCleanTitle(title string) string {
break
}
// Remove trailing " - version" patterns
dashPatterns := []string{
" - remaster", " - remastered", " - single version", " - radio edit",
" - live", " - acoustic", " - demo", " - remix",
@@ -298,7 +274,6 @@ func qobuzCleanTitle(title string) string {
}
}
// Remove multiple spaces
for strings.Contains(cleaned, " ") {
cleaned = strings.ReplaceAll(cleaned, " ", " ")
}
@@ -358,7 +333,6 @@ func containsQueryQobuz(queries []string, query string) bool {
return false
}
// NewQobuzDownloader creates a new Qobuz downloader (returns singleton for connection reuse)
func NewQobuzDownloader() *QobuzDownloader {
qobuzDownloaderOnce.Do(func() {
globalQobuzDownloader = &QobuzDownloader{
@@ -369,7 +343,6 @@ func NewQobuzDownloader() *QobuzDownloader {
return globalQobuzDownloader
}
// GetTrackByID fetches track info directly by Qobuz track ID
func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) {
// Qobuz API: /track/get?track_id=XXX
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9nZXQ/dHJhY2tfaWQ9")
@@ -420,7 +393,6 @@ func (q *QobuzDownloader) GetAvailableAPIs() []string {
return apis
}
// SearchTrackByISRC searches for a track by ISRC
func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) {
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(isrc), q.appID)
@@ -463,7 +435,6 @@ func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) {
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
}
// SearchTrackByISRCWithTitle searches for a track by ISRC with duration verification
// expectedDurationSec is the expected duration in seconds (0 to skip verification)
func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDurationSec int) (*QobuzTrack, error) {
GoLog("[Qobuz] Searching by ISRC: %s\n", isrc)
@@ -508,7 +479,6 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
GoLog("[Qobuz] Found %d exact ISRC matches\n", len(isrcMatches))
if len(isrcMatches) > 0 {
// Verify duration if provided
if expectedDurationSec > 0 {
var durationVerifiedMatches []*QobuzTrack
for _, track := range isrcMatches {
@@ -516,7 +486,6 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
if durationDiff < 0 {
durationDiff = -durationDiff
}
// Allow 10 seconds tolerance
if durationDiff <= 10 {
durationVerifiedMatches = append(durationVerifiedMatches, track)
}
@@ -528,14 +497,12 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
return durationVerifiedMatches[0], nil
}
// ISRC matches but duration doesn't
GoLog("[Qobuz] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n",
isrc, expectedDurationSec, isrcMatches[0].Duration)
return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version)",
expectedDurationSec, isrcMatches[0].Duration)
}
// No duration to verify, return first match
GoLog("[Qobuz] ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title)
return isrcMatches[0], nil
}
@@ -547,17 +514,14 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
}
// SearchTrackByISRCWithTitle is deprecated, use SearchTrackByISRCWithDuration instead
func (q *QobuzDownloader) SearchTrackByISRCWithTitle(isrc, expectedTitle string) (*QobuzTrack, error) {
return q.SearchTrackByISRCWithDuration(isrc, 0)
}
// SearchTrackByMetadata searches for a track using artist name and track name
func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*QobuzTrack, error) {
return q.SearchTrackByMetadataWithDuration(trackName, artistName, 0)
}
// SearchTrackByMetadataWithDuration searches for a track with duration verification
// Now includes romaji conversion for Japanese text (same as Tidal)
// Also includes title verification to prevent wrong song downloads
func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) {
@@ -696,7 +660,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
}
if len(durationMatches) > 0 {
// Return best quality among duration matches
for _, track := range durationMatches {
if track.MaximumBitDepth >= 24 {
GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title+duration verified, hi-res)\n",
@@ -709,7 +672,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
return durationMatches[0], nil
}
// No duration match found
return nil, fmt.Errorf("no tracks found with matching title and duration (expected '%s', %ds)", trackName, expectedDurationSec)
}
@@ -739,8 +701,6 @@ type qobuzAPIResult struct {
duration time.Duration
}
// getQobuzDownloadURLParallel requests download URL from all APIs in parallel
// "Siapa cepat dia dapat" - first successful response wins
func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (string, string, error) {
if len(apis) == 0 {
return "", "", fmt.Errorf("no APIs available")
@@ -756,9 +716,7 @@ func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (
go func(api string) {
reqStart := time.Now()
client := &http.Client{
Timeout: 15 * time.Second,
}
client := NewHTTPClientWithTimeout(15 * time.Second)
reqURL := fmt.Sprintf("%s%d&quality=%s", api, trackID, quality)
@@ -847,15 +805,12 @@ func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (
return "", "", fmt.Errorf("all %d Qobuz APIs failed. Errors: %v", len(apis), errors)
}
// GetDownloadURL gets download URL for a track - tries ALL APIs in parallel
// "Siapa cepat dia dapat" - first successful response wins
func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, error) {
apis := q.GetAvailableAPIs()
if len(apis) == 0 {
return "", fmt.Errorf("no Qobuz API available")
}
// Use parallel approach - request from all APIs simultaneously
_, downloadURL, err := getQobuzDownloadURLParallel(apis, trackID, quality)
if err != nil {
return "", err
@@ -899,7 +854,6 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
}
expectedSize := resp.ContentLength
// Set total bytes if available
if expectedSize > 0 && itemID != "" {
SetItemBytesTotal(itemID, expectedSize)
}
@@ -909,16 +863,13 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
return err
}
// Use buffered writer for better performance (256KB buffer)
bufWriter := bufio.NewWriterSize(out, 256*1024)
// Use item progress writer with buffered output
var written int64
if itemID != "" {
progressWriter := NewItemProgressWriter(bufWriter, itemID)
written, err = io.Copy(progressWriter, resp.Body)
} else {
// Fallback: direct copy without progress tracking
written, err = io.Copy(bufWriter, resp.Body)
}
@@ -926,7 +877,6 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
flushErr := bufWriter.Flush()
closeErr := out.Close()
// Check for any errors
if err != nil {
os.Remove(outputPath)
if isDownloadCancelled(itemID) {
@@ -952,7 +902,6 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
return nil
}
// QobuzDownloadResult contains download result with quality info
type QobuzDownloadResult struct {
FilePath string
BitDepth int
@@ -966,22 +915,18 @@ type QobuzDownloadResult struct {
ISRC string
}
// downloadFromQobuz downloads a track using the request parameters
func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
downloader := NewQobuzDownloader()
// Check for existing file first
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
return QobuzDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
}
// Convert expected duration from ms to seconds
expectedDurationSec := req.DurationMS / 1000
var track *QobuzTrack
var err error
// STRATEGY 0: Use pre-fetched Qobuz ID from Odesli enrichment (highest priority)
if req.QobuzID != "" {
GoLog("[Qobuz] Using Qobuz ID from Odesli enrichment: %s\n", req.QobuzID)
var trackID int64
@@ -1052,7 +997,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
}
// Build filename
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
"title": req.TrackName,
"artist": req.ArtistName,
@@ -1064,7 +1008,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
filename = sanitizeFilename(filename) + ".flac"
outputPath := filepath.Join(req.OutputDir, filename)
// Check if file already exists
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
return QobuzDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
}
@@ -1083,12 +1026,10 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
}
GoLog("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality)
// Get actual quality from track metadata
actualBitDepth := track.MaximumBitDepth
actualSampleRate := int(track.MaximumSamplingRate * 1000) // Convert kHz to Hz
GoLog("[Qobuz] Actual quality: %d-bit/%.1fkHz\n", actualBitDepth, track.MaximumSamplingRate)
// Get download URL using parallel API requests
downloadURL, err := downloader.GetDownloadURL(track.ID, qobuzQuality)
if err != nil {
return QobuzDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
@@ -1106,6 +1047,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
req.TrackName,
req.ArtistName,
req.EmbedLyrics,
int64(req.DurationMS),
)
}()
@@ -1120,34 +1062,37 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
// Wait for parallel operations to complete
<-parallelDone
// Set progress to 100% and status to finalizing (before embedding)
// This makes the UI show "Finalizing..." while embedding happens
if req.ItemID != "" {
SetItemProgress(req.ItemID, 1.0, 0, 0)
SetItemFinalizing(req.ItemID)
}
// Embed metadata using parallel-fetched cover data
// Use metadata from the actual Qobuz track found (more accurate than request) but prefer
// requested Album Name to avoid ISRC version mismatches (e.g. Compilations vs Original)
albumName := track.Album.Title
if req.AlbumName != "" {
albumName = req.AlbumName
}
// Use track number from request if available, otherwise from Qobuz API
actualTrackNumber := req.TrackNumber
if actualTrackNumber == 0 {
actualTrackNumber = track.TrackNumber
}
metadata := Metadata{
Title: track.Title,
Artist: track.Performer.Name,
Album: albumName,
AlbumArtist: req.AlbumArtist, // Qobuz track struct might not have this handy, keep req or check album struct
Date: track.Album.ReleaseDate,
TrackNumber: track.TrackNumber,
TrackNumber: actualTrackNumber,
TotalTracks: req.TotalTracks,
DiscNumber: req.DiscNumber, // QobuzTrack struct usually doesn't have disc info in simple search result
ISRC: track.ISRC,
Genre: req.Genre, // From Deezer album metadata
Label: req.Label, // From Deezer album metadata
Copyright: req.Copyright, // From Deezer album metadata
}
// Use cover data from parallel fetch
var coverData []byte
if parallelResult != nil && parallelResult.CoverData != nil {
coverData = parallelResult.CoverData
@@ -1158,13 +1103,28 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
}
// Embed lyrics from parallel fetch
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
GoLog("[Qobuz] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
GoLog("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr)
} else {
fmt.Println("[Qobuz] Lyrics embedded successfully")
lyricsMode := req.LyricsMode
if lyricsMode == "" {
lyricsMode = "embed"
}
if lyricsMode == "external" || lyricsMode == "both" {
GoLog("[Qobuz] Saving external LRC file...\n")
if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil {
GoLog("[Qobuz] Warning: failed to save LRC file: %v\n", lrcErr)
} else {
GoLog("[Qobuz] LRC file saved: %s\n", lrcPath)
}
}
if lyricsMode == "embed" || lyricsMode == "both" {
GoLog("[Qobuz] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
GoLog("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr)
} else {
fmt.Println("[Qobuz] Lyrics embedded successfully")
}
}
} else if req.EmbedLyrics {
fmt.Println("[Qobuz] No lyrics available from parallel fetch")
@@ -1181,7 +1141,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
Artist: track.Performer.Name,
Album: track.Album.Title,
ReleaseDate: track.Album.ReleaseDate,
TrackNumber: track.TrackNumber,
TrackNumber: actualTrackNumber,
DiscNumber: req.DiscNumber, // Qobuz track struct limitations
ISRC: track.ISRC,
}, nil
-14
View File
@@ -5,7 +5,6 @@ import (
"time"
)
// RateLimiter implements a sliding window rate limiter
type RateLimiter struct {
mu sync.Mutex
maxRequests int
@@ -13,7 +12,6 @@ type RateLimiter struct {
timestamps []time.Time
}
// NewRateLimiter creates a new rate limiter with specified max requests per window
func NewRateLimiter(maxRequests int, window time.Duration) *RateLimiter {
return &RateLimiter{
maxRequests: maxRequests,
@@ -22,39 +20,31 @@ func NewRateLimiter(maxRequests int, window time.Duration) *RateLimiter {
}
}
// WaitForSlot blocks until a request is allowed under the rate limit
// Returns immediately if under the limit, otherwise waits until a slot is available
func (r *RateLimiter) WaitForSlot() {
r.mu.Lock()
defer r.mu.Unlock()
now := time.Now()
// Remove timestamps outside the window
r.cleanOldTimestamps(now)
// If under limit, record and return immediately
if len(r.timestamps) < r.maxRequests {
r.timestamps = append(r.timestamps, now)
return
}
// Calculate wait time until oldest timestamp expires
oldestTimestamp := r.timestamps[0]
waitUntil := oldestTimestamp.Add(r.window)
waitDuration := waitUntil.Sub(now)
if waitDuration > 0 {
// Release lock while waiting
r.mu.Unlock()
time.Sleep(waitDuration)
r.mu.Lock()
// Clean again after waiting
r.cleanOldTimestamps(time.Now())
}
// Record this request
r.timestamps = append(r.timestamps, time.Now())
}
@@ -76,8 +66,6 @@ func (r *RateLimiter) cleanOldTimestamps(now time.Time) {
}
}
// TryAcquire attempts to acquire a slot without blocking
// Returns true if successful, false if rate limit would be exceeded
func (r *RateLimiter) TryAcquire() bool {
r.mu.Lock()
defer r.mu.Unlock()
@@ -93,7 +81,6 @@ func (r *RateLimiter) TryAcquire() bool {
return false
}
// Available returns the number of requests available in the current window
func (r *RateLimiter) Available() int {
r.mu.Lock()
defer r.mu.Unlock()
@@ -105,7 +92,6 @@ func (r *RateLimiter) Available() int {
// Global SongLink rate limiter - 9 requests per minute (to be safe, limit is 10)
var songLinkRateLimiter = NewRateLimiter(9, time.Minute)
// GetSongLinkRateLimiter returns the global SongLink rate limiter
func GetSongLinkRateLimiter() *RateLimiter {
return songLinkRateLimiter
}
-11
View File
@@ -5,7 +5,6 @@ import (
"unicode"
)
// Hiragana to Romaji mapping
var hiraganaToRomaji = map[rune]string{
'あ': "a", 'い': "i", 'う': "u", 'え': "e", 'お': "o",
'か': "ka", 'き': "ki", 'く': "ku", 'け': "ke", 'こ': "ko",
@@ -30,7 +29,6 @@ var hiraganaToRomaji = map[rune]string{
'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e", 'ぉ': "o",
}
// Katakana to Romaji mapping
var katakanaToRomaji = map[rune]string{
'ア': "a", 'イ': "i", 'ウ': "u", 'エ': "e", 'オ': "o",
'カ': "ka", 'キ': "ki", 'ク': "ku", 'ケ': "ke", 'コ': "ko",
@@ -58,7 +56,6 @@ var katakanaToRomaji = map[rune]string{
'ヴ': "vu",
}
// Combination mappings for きゃ, しゃ, etc.
var combinationHiragana = map[string]string{
"きゃ": "kya", "きゅ": "kyu", "きょ": "kyo",
"しゃ": "sha", "しゅ": "shu", "しょ": "sho",
@@ -91,7 +88,6 @@ var combinationKatakana = map[string]string{
"ウィ": "wi", "ウェ": "we", "ウォ": "wo",
}
// ContainsJapanese checks if a string contains Japanese characters
func ContainsJapanese(s string) bool {
for _, r := range s {
if isHiragana(r) || isKatakana(r) || isKanji(r) {
@@ -114,8 +110,6 @@ func isKanji(r rune) bool {
(r >= 0x3400 && r <= 0x4DBF) // CJK Unified Ideographs Extension A
}
// JapaneseToRomaji converts Japanese text (hiragana/katakana) to romaji
// Note: Kanji cannot be converted without a dictionary, so they are kept as-is
func JapaneseToRomaji(text string) string {
if !ContainsJapanese(text) {
return text
@@ -175,8 +169,6 @@ func JapaneseToRomaji(text string) string {
return result.String()
}
// BuildSearchQuery creates a search query from track name and artist
// Converts Japanese to romaji if present
func BuildSearchQuery(trackName, artistName string) string {
// Convert Japanese to romaji
trackRomaji := JapaneseToRomaji(trackName)
@@ -189,7 +181,6 @@ func BuildSearchQuery(trackName, artistName string) string {
return strings.TrimSpace(artistClean + " " + trackClean)
}
// cleanSearchQuery removes special characters that might interfere with search
func cleanSearchQuery(s string) string {
var result strings.Builder
for _, r := range s {
@@ -202,8 +193,6 @@ func cleanSearchQuery(s string) string {
return strings.TrimSpace(result.String())
}
// CleanToASCII removes all non-ASCII characters and keeps only letters, numbers, spaces
// This is useful for creating search queries that work better with Tidal's search
func CleanToASCII(s string) string {
var result strings.Builder
for _, r := range s {
+1 -45
View File
@@ -11,12 +11,10 @@ import (
"time"
)
// SongLinkClient handles song.link API interactions
type SongLinkClient struct {
client *http.Client
}
// TrackAvailability represents track availability on different platforms
type TrackAvailability struct {
SpotifyID string `json:"spotify_id"`
Tidal bool `json:"tidal"`
@@ -31,32 +29,26 @@ type TrackAvailability struct {
}
var (
// Global SongLink client instance for connection reuse
globalSongLinkClient *SongLinkClient
songLinkClientOnce sync.Once
)
// NewSongLinkClient creates a new SongLink client (returns singleton for connection reuse)
func NewSongLinkClient() *SongLinkClient {
songLinkClientOnce.Do(func() {
globalSongLinkClient = &SongLinkClient{
client: NewHTTPClientWithTimeout(SongLinkTimeout), // 30s timeout
client: NewHTTPClientWithTimeout(SongLinkTimeout),
}
})
return globalSongLinkClient
}
// CheckTrackAvailability checks track availability on streaming platforms
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
// Validate Spotify ID format (should be 22 characters alphanumeric)
if spotifyTrackID == "" {
return nil, fmt.Errorf("spotify track ID is empty")
}
// Use global rate limiter - blocks until request is allowed
songLinkRateLimiter.WaitForSlot()
// Build API URL
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
@@ -68,7 +60,6 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
return nil, fmt.Errorf("failed to create request: %w", err)
}
// Use retry logic with User-Agent
retryConfig := DefaultRetryConfig()
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
if err != nil {
@@ -76,7 +67,6 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
}
defer resp.Body.Close()
// Handle specific error codes
if resp.StatusCode == 400 {
return nil, fmt.Errorf("track not found on SongLink (invalid Spotify ID or track unavailable)")
}
@@ -109,27 +99,22 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
SpotifyID: spotifyTrackID,
}
// Check Tidal
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
availability.Tidal = true
availability.TidalURL = tidalLink.URL
}
// Check Amazon
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
availability.Amazon = true
availability.AmazonURL = amazonLink.URL
}
// Check Deezer
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
availability.Deezer = true
availability.DeezerURL = deezerLink.URL
// Extract Deezer ID from URL (e.g., https://www.deezer.com/track/123456)
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
}
// Check Qobuz using ISRC (SongLink doesn't support Qobuz directly)
if isrc != "" {
availability.Qobuz = checkQobuzAvailability(isrc)
}
@@ -137,7 +122,6 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
return availability, nil
}
// GetStreamingURLs gets streaming URLs for a Spotify track
func (s *SongLinkClient) GetStreamingURLs(spotifyTrackID string) (map[string]string, error) {
availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
if err != nil {
@@ -191,12 +175,9 @@ func checkQobuzAvailability(isrc string) bool {
// extractDeezerIDFromURL extracts Deezer track/album/artist ID from URL
func extractDeezerIDFromURL(deezerURL string) string {
// URL format: https://www.deezer.com/track/123456 or https://www.deezer.com/en/track/123456
parts := strings.Split(deezerURL, "/")
if len(parts) > 0 {
// Get the last part which should be the ID
lastPart := parts[len(parts)-1]
// Remove any query parameters
if idx := strings.Index(lastPart, "?"); idx > 0 {
lastPart = lastPart[:idx]
}
@@ -205,7 +186,6 @@ func extractDeezerIDFromURL(deezerURL string) string {
return ""
}
// GetDeezerIDFromSpotify converts a Spotify track ID to Deezer track ID using SongLink
func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string, error) {
availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
if err != nil {
@@ -227,7 +207,6 @@ type AlbumAvailability struct {
DeezerID string `json:"deezer_id,omitempty"`
}
// CheckAlbumAvailability checks album availability on streaming platforms using SongLink
func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAvailability, error) {
// Use global rate limiter
songLinkRateLimiter.WaitForSlot()
@@ -274,7 +253,6 @@ func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAv
SpotifyID: spotifyAlbumID,
}
// Check Deezer
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
availability.Deezer = true
availability.DeezerURL = deezerLink.URL
@@ -298,24 +276,16 @@ func (s *SongLinkClient) GetDeezerAlbumIDFromSpotify(spotifyAlbumID string) (str
return availability.DeezerID, nil
}
// ========================================
// Deezer ID Support - Query SongLink using Deezer as source
// ========================================
// CheckAvailabilityFromDeezer checks track availability using Deezer track ID as source
// This is useful when we have Deezer metadata and want to find the track on other platforms
func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*TrackAvailability, error) {
if deezerTrackID == "" {
return nil, fmt.Errorf("deezer track ID is empty")
}
// Use global rate limiter
songLinkRateLimiter.WaitForSlot()
// Build Deezer URL
deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID)
// Build API URL using Deezer URL as source
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
apiURL := fmt.Sprintf("%s%s&userCountry=US", string(apiBase), url.QueryEscape(deezerURL))
@@ -371,25 +341,20 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra
DeezerID: deezerTrackID,
}
// Check Spotify
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
// Extract Spotify ID from URL
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
}
// Check Tidal
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
availability.Tidal = true
availability.TidalURL = tidalLink.URL
}
// Check Amazon
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
availability.Amazon = true
availability.AmazonURL = amazonLink.URL
}
// Check Deezer URL
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
availability.DeezerURL = deezerLink.URL
}
@@ -397,7 +362,6 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra
return availability, nil
}
// CheckAvailabilityByPlatform checks track availability using any supported platform
// platform: "spotify", "deezer", "tidal", "amazonMusic", "appleMusic", "youtube", etc.
// entityType: "song" or "album"
// entityID: the ID on that platform
@@ -459,24 +423,20 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
availability := &TrackAvailability{}
// Check Spotify
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
}
// Check Tidal
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
availability.Tidal = true
availability.TidalURL = tidalLink.URL
}
// Check Amazon
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
availability.Amazon = true
availability.AmazonURL = amazonLink.URL
}
// Check Deezer
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
availability.Deezer = true
availability.DeezerURL = deezerLink.URL
@@ -488,10 +448,8 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
// extractSpotifyIDFromURL extracts Spotify track ID from URL
func extractSpotifyIDFromURL(spotifyURL string) string {
// URL format: https://open.spotify.com/track/0Jcij1eWd5bDMU5iPbxe2i
parts := strings.Split(spotifyURL, "/track/")
if len(parts) > 1 {
// Get the ID part and remove any query parameters
idPart := parts[1]
if idx := strings.Index(idPart, "?"); idx > 0 {
idPart = idPart[:idx]
@@ -501,7 +459,6 @@ func extractSpotifyIDFromURL(spotifyURL string) string {
return ""
}
// GetSpotifyIDFromDeezer converts a Deezer track ID to Spotify track ID using SongLink
func (s *SongLinkClient) GetSpotifyIDFromDeezer(deezerTrackID string) (string, error) {
availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID)
if err != nil {
@@ -529,7 +486,6 @@ func (s *SongLinkClient) GetTidalURLFromDeezer(deezerTrackID string) (string, er
return availability.TidalURL, nil
}
// GetAmazonURLFromDeezer converts a Deezer track ID to Amazon Music URL using SongLink
func (s *SongLinkClient) GetAmazonURLFromDeezer(deezerTrackID string) (string, error) {
availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID)
if err != nil {
+21 -74
View File
@@ -24,7 +24,6 @@ const (
artistAlbumsURL = "https://api.spotify.com/v1/artists/%s/albums"
searchBaseURL = "https://api.spotify.com/v1/search"
// Cache TTL settings
artistCacheTTL = 10 * time.Minute
searchCacheTTL = 5 * time.Minute
albumCacheTTL = 10 * time.Minute
@@ -32,7 +31,6 @@ const (
var errInvalidSpotifyURL = errors.New("invalid or unsupported Spotify URL")
// cacheEntry holds cached data with expiration
type cacheEntry struct {
data interface{}
expiresAt time.Time
@@ -42,26 +40,23 @@ func (e *cacheEntry) isExpired() bool {
return time.Now().After(e.expiresAt)
}
// SpotifyMetadataClient handles Spotify API interactions
type SpotifyMetadataClient struct {
httpClient *http.Client
clientID string
clientSecret string
cachedToken string
tokenExpiresAt time.Time
tokenMu sync.Mutex // Protects token cache for concurrent access
tokenMu sync.Mutex
rng *rand.Rand
rngMu sync.Mutex
userAgent string
// Caches to reduce API calls
artistCache map[string]*cacheEntry // key: artistID
searchCache map[string]*cacheEntry // key: query+type
albumCache map[string]*cacheEntry // key: albumID
artistCache map[string]*cacheEntry
searchCache map[string]*cacheEntry
albumCache map[string]*cacheEntry
cacheMu sync.RWMutex
}
// Custom credentials storage (set from Flutter)
var (
customClientID string
customClientSecret string
@@ -79,17 +74,14 @@ func SetSpotifyCredentials(clientID, clientSecret string) {
customClientSecret = clientSecret
}
// HasSpotifyCredentials checks if Spotify credentials are configured
func HasSpotifyCredentials() bool {
credentialsMu.RLock()
defer credentialsMu.RUnlock()
// Check custom credentials first
if customClientID != "" && customClientSecret != "" {
return true
}
// Check environment variables
if os.Getenv("SPOTIFY_CLIENT_ID") != "" && os.Getenv("SPOTIFY_CLIENT_SECRET") != "" {
return true
}
@@ -102,12 +94,10 @@ func getCredentials() (string, string, error) {
credentialsMu.RLock()
defer credentialsMu.RUnlock()
// Check custom credentials first
if customClientID != "" && customClientSecret != "" {
return customClientID, customClientSecret, nil
}
// Check environment variables
clientID := os.Getenv("SPOTIFY_CLIENT_ID")
clientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET")
@@ -115,14 +105,10 @@ func getCredentials() (string, string, error) {
return clientID, clientSecret, nil
}
// No credentials available
return "", "", ErrNoSpotifyCredentials
}
// NewSpotifyMetadataClient creates a new Spotify client
// Returns error if credentials are not configured
func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) {
// Get credentials - will error if not configured
clientID, clientSecret, err := getCredentials()
if err != nil {
return nil, err
@@ -131,7 +117,7 @@ func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) {
src := rand.NewSource(time.Now().UnixNano())
c := &SpotifyMetadataClient{
httpClient: NewHTTPClientWithTimeout(15 * time.Second), // Use shared transport for connection pooling
httpClient: NewHTTPClientWithTimeout(15 * time.Second),
clientID: clientID,
clientSecret: clientSecret,
rng: rand.New(src),
@@ -143,7 +129,6 @@ func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) {
return c, nil
}
// TrackMetadata represents track information
type TrackMetadata struct {
SpotifyID string `json:"spotify_id,omitempty"`
Artists string `json:"artists"`
@@ -161,7 +146,6 @@ type TrackMetadata struct {
AlbumType string `json:"album_type,omitempty"` // album, single, ep, compilation
}
// AlbumTrackMetadata holds per-track info for album/playlist
type AlbumTrackMetadata struct {
SpotifyID string `json:"spotify_id,omitempty"`
Artists string `json:"artists"`
@@ -178,25 +162,26 @@ type AlbumTrackMetadata struct {
ISRC string `json:"isrc"`
AlbumID string `json:"album_id,omitempty"`
AlbumURL string `json:"album_url,omitempty"`
AlbumType string `json:"album_type,omitempty"` // album, single, ep, compilation
AlbumType string `json:"album_type,omitempty"`
}
// AlbumInfoMetadata holds album information
type AlbumInfoMetadata struct {
TotalTracks int `json:"total_tracks"`
Name string `json:"name"`
ReleaseDate string `json:"release_date"`
Artists string `json:"artists"`
ArtistId string `json:"artist_id,omitempty"`
Images string `json:"images"`
Genre string `json:"genre,omitempty"`
Label string `json:"label,omitempty"`
Copyright string `json:"copyright,omitempty"`
}
// AlbumResponsePayload is the response for album requests
type AlbumResponsePayload struct {
AlbumInfo AlbumInfoMetadata `json:"album_info"`
TrackList []AlbumTrackMetadata `json:"track_list"`
}
// PlaylistInfoMetadata holds playlist information
type PlaylistInfoMetadata struct {
Tracks struct {
Total int `json:"total"`
@@ -208,13 +193,11 @@ type PlaylistInfoMetadata struct {
} `json:"owner"`
}
// PlaylistResponsePayload is the response for playlist requests
type PlaylistResponsePayload struct {
PlaylistInfo PlaylistInfoMetadata `json:"playlist_info"`
TrackList []AlbumTrackMetadata `json:"track_list"`
}
// ArtistInfoMetadata holds artist information
type ArtistInfoMetadata struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -223,7 +206,6 @@ type ArtistInfoMetadata struct {
Popularity int `json:"popularity"`
}
// ArtistAlbumMetadata holds album info for artist discography
type ArtistAlbumMetadata struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -234,24 +216,20 @@ type ArtistAlbumMetadata struct {
Artists string `json:"artists"`
}
// ArtistResponsePayload is the response for artist requests
type ArtistResponsePayload struct {
ArtistInfo ArtistInfoMetadata `json:"artist_info"`
Albums []ArtistAlbumMetadata `json:"albums"`
}
// TrackResponse is the response for single track requests
type TrackResponse struct {
Track TrackMetadata `json:"track"`
}
// SearchResult represents search results
type SearchResult struct {
Tracks []TrackMetadata `json:"tracks"`
Total int `json:"total"`
}
// SearchArtistResult represents an artist in search results
type SearchArtistResult struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -260,7 +238,6 @@ type SearchArtistResult struct {
Popularity int `json:"popularity"`
}
// SearchAllResult represents combined search results for tracks and artists
type SearchAllResult struct {
Tracks []TrackMetadata `json:"tracks"`
Artists []SearchArtistResult `json:"artists"`
@@ -277,7 +254,6 @@ type accessTokenResponse struct {
TokenType string `json:"token_type"`
}
// Internal API response types
type image struct {
URL string `json:"url"`
}
@@ -303,7 +279,7 @@ type albumSimplified struct {
Images []image `json:"images"`
ExternalURL externalURL `json:"external_urls"`
Artists []artist `json:"artists"`
AlbumType string `json:"album_type"` // album, single, compilation
AlbumType string `json:"album_type"`
}
type trackFull struct {
@@ -318,7 +294,6 @@ type trackFull struct {
Artists []artist `json:"artists"`
}
// GetFilteredData fetches and formats Spotify data
func (c *SpotifyMetadataClient) GetFilteredData(ctx context.Context, spotifyURL string, batch bool, delay time.Duration) (interface{}, error) {
parsed, err := parseSpotifyURI(spotifyURL)
if err != nil {
@@ -344,7 +319,6 @@ func (c *SpotifyMetadataClient) GetFilteredData(ctx context.Context, spotifyURL
}
}
// SearchTracks searches for tracks on Spotify
func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string, limit int) (*SearchResult, error) {
token, err := c.getAccessToken(ctx)
if err != nil {
@@ -391,12 +365,9 @@ func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string,
return result, nil
}
// SearchAll searches for tracks and artists on Spotify
func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) {
// Create cache key
cacheKey := fmt.Sprintf("all:%s:%d:%d", query, trackLimit, artistLimit)
// Check cache first
c.cacheMu.RLock()
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
c.cacheMu.RUnlock()
@@ -456,7 +427,6 @@ func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, tra
})
}
// Limit artists to artistLimit
artistCount := len(response.Artists.Items)
if artistCount > artistLimit {
artistCount = artistLimit
@@ -473,7 +443,6 @@ func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, tra
})
}
// Store in cache
c.cacheMu.Lock()
c.searchCache[cacheKey] = &cacheEntry{
data: result,
@@ -510,7 +479,6 @@ func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID, token s
}
func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token string) (*AlbumResponsePayload, error) {
// Check cache first
c.cacheMu.RLock()
if entry, ok := c.albumCache[albumID]; ok && !entry.isExpired() {
c.cacheMu.RUnlock()
@@ -518,7 +486,6 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
}
c.cacheMu.RUnlock()
// Track item structure for pagination
type trackItem struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -546,19 +513,25 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
}
albumImage := firstImageURL(data.Images)
// Get first artist ID
var firstArtistId string
if len(data.Artists) > 0 {
firstArtistId = data.Artists[0].ID
}
info := AlbumInfoMetadata{
TotalTracks: data.TotalTracks,
Name: data.Name,
ReleaseDate: data.ReleaseDate,
Artists: joinArtists(data.Artists),
ArtistId: firstArtistId,
Images: albumImage,
}
// Collect all tracks (including paginated)
allTrackItems := data.Tracks.Items
nextURL := data.Tracks.Next
// Fetch remaining tracks using pagination (no limit)
for nextURL != "" {
var pageData struct {
Items []trackItem `json:"items"`
@@ -580,7 +553,6 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
trackIDs[i] = item.ID
}
// Fetch ISRCs in parallel for ALL tracks (like Deezer implementation)
isrcMap := c.fetchISRCsParallel(ctx, trackIDs, token)
tracks := make([]AlbumTrackMetadata, 0, len(allTrackItems))
@@ -610,7 +582,6 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
TrackList: tracks,
}
// Store in cache
c.cacheMu.Lock()
c.albumCache[albumID] = &cacheEntry{
data: result,
@@ -621,10 +592,8 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
return result, nil
}
// fetchISRCsParallel fetches ISRCs for multiple tracks in parallel
// Similar to Deezer implementation for consistency
func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs []string, token string) map[string]string {
const maxParallelISRC = 10 // Max concurrent ISRC fetches
const maxParallelISRC = 10
result := make(map[string]string)
var resultMu sync.Mutex
@@ -633,7 +602,6 @@ func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs
return result
}
// Use semaphore to limit concurrent requests
sem := make(chan struct{}, maxParallelISRC)
var wg sync.WaitGroup
@@ -642,7 +610,6 @@ func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs
go func(id string) {
defer wg.Done()
// Acquire semaphore
select {
case sem <- struct{}{}:
defer func() { <-sem }()
@@ -663,7 +630,6 @@ func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs
}
func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, token string) (*PlaylistResponsePayload, error) {
// First request to get playlist info and first batch of tracks
var data struct {
Name string `json:"name"`
Images []image `json:"images"`
@@ -689,10 +655,8 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
info.Owner.Name = data.Name
info.Owner.Images = firstImageURL(data.Images)
// Pre-allocate with expected capacity
tracks := make([]AlbumTrackMetadata, 0, data.Tracks.Total)
// Add first batch of tracks
for _, item := range data.Tracks.Items {
if item.Track == nil {
continue
@@ -716,7 +680,6 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
})
}
// Fetch remaining tracks using pagination (NO LIMIT - fetch all tracks)
nextURL := data.Tracks.Next
for nextURL != "" {
@@ -728,7 +691,6 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
}
if err := c.getJSON(ctx, nextURL, token, &pageData); err != nil {
// Log error but return what we have so far
fmt.Printf("[Spotify] Warning: failed to fetch page, returning %d tracks: %v\n", len(tracks), err)
break
}
@@ -768,7 +730,6 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
}
func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token string) (*ArtistResponsePayload, error) {
// Check cache first
c.cacheMu.RLock()
if entry, ok := c.artistCache[artistID]; ok && !entry.isExpired() {
c.cacheMu.RUnlock()
@@ -776,7 +737,6 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token
}
c.cacheMu.RUnlock()
// Fetch artist info
var artistData struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -799,7 +759,6 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token
Popularity: artistData.Popularity,
}
// Fetch artist albums (all types: album, single, compilation)
albums := make([]ArtistAlbumMetadata, 0)
offset := 0
limit := 50
@@ -839,13 +798,11 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token
})
}
// Check if there are more albums
if albumsData.Next == "" || len(albumsData.Items) < limit {
break
}
offset += limit
// Safety limit to prevent infinite loops
if offset > 500 {
break
}
@@ -856,7 +813,6 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token
Albums: albums,
}
// Store in cache
c.cacheMu.Lock()
c.artistCache[artistID] = &cacheEntry{
data: result,
@@ -927,7 +883,6 @@ func (c *SpotifyMetadataClient) getJSON(ctx context.Context, endpoint, token str
return err
}
// Set headers (same as PC version baseHeaders)
req.Header.Set("User-Agent", c.userAgent)
req.Header.Set("Accept", "application/json")
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
@@ -963,8 +918,7 @@ func (c *SpotifyMetadataClient) randomUserAgent() string {
c.rngMu.Lock()
defer c.rngMu.Unlock()
// Use Mac User-Agent format (same as PC version)
macMajor := c.rng.Intn(4) + 11 // 11-14
macMajor := c.rng.Intn(4) + 11
macMinor := c.rng.Intn(5) + 4 // 4-8
webkitMajor := c.rng.Intn(7) + 530 // 530-536
webkitMinor := c.rng.Intn(7) + 30 // 30-36
@@ -989,7 +943,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
return spotifyURI{}, errInvalidSpotifyURL
}
// Handle spotify: URI format
if strings.HasPrefix(trimmed, "spotify:") {
parts := strings.Split(trimmed, ":")
if len(parts) == 3 {
@@ -1000,13 +953,11 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
}
}
// Handle URL format
parsed, err := url.Parse(trimmed)
if err != nil {
return spotifyURI{}, err
}
// Handle embed.spotify.com URLs
if parsed.Host == "embed.spotify.com" {
if parsed.RawQuery == "" {
return spotifyURI{}, errInvalidSpotifyURL
@@ -1019,7 +970,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
return parseSpotifyURI(embedded)
}
// Handle plain ID (no scheme/host) - defaults to playlist
if parsed.Scheme == "" && parsed.Host == "" {
id := strings.Trim(strings.TrimSpace(parsed.Path), "/")
if id == "" {
@@ -1045,7 +995,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
return spotifyURI{}, errInvalidSpotifyURL
}
// Skip intl- prefix if present
if strings.HasPrefix(parts[0], "intl-") {
parts = parts[1:]
}
@@ -1053,7 +1002,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
return spotifyURI{}, errInvalidSpotifyURL
}
// Handle standard URLs: /album/{id}, /track/{id}, /playlist/{id}, /artist/{id}
if len(parts) == 2 {
switch parts[0] {
case "album", "track", "playlist", "artist":
@@ -1061,7 +1009,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
}
}
// Handle nested playlist URLs: /user/{user}/playlist/{id}
if len(parts) == 4 && parts[2] == "playlist" {
return spotifyURI{Type: "playlist", ID: parts[3]}, nil
}
+67 -254
View File
File diff suppressed because it is too large Load Diff
+100 -2
View File
@@ -142,6 +142,27 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
case "checkDuplicatesBatch":
let args = call.arguments as! [String: Any]
let outputDir = args["output_dir"] as! String
let tracksJson = args["tracks"] as? String ?? "[]"
let response = GobackendCheckDuplicatesBatch(outputDir, tracksJson, &error)
if let error = error { throw error }
return response
case "preBuildDuplicateIndex":
let args = call.arguments as! [String: Any]
let outputDir = args["output_dir"] as! String
GobackendPreBuildDuplicateIndex(outputDir, &error)
if let error = error { throw error }
return nil
case "invalidateDuplicateIndex":
let args = call.arguments as! [String: Any]
let outputDir = args["output_dir"] as! String
GobackendInvalidateDuplicateIndex(outputDir)
return nil
case "buildFilename":
let args = call.arguments as! [String: Any]
let template = args["template"] as! String
@@ -161,7 +182,8 @@ import Gobackend // Import Go framework
let spotifyId = args["spotify_id"] as! String
let trackName = args["track_name"] as! String
let artistName = args["artist_name"] as! String
let response = GobackendFetchLyrics(spotifyId, trackName, artistName, &error)
let durationMs = args["duration_ms"] as? Int64 ?? 0
let response = GobackendFetchLyrics(spotifyId, trackName, artistName, durationMs, &error)
if let error = error { throw error }
return response
@@ -171,7 +193,8 @@ import Gobackend // Import Go framework
let trackName = args["track_name"] as! String
let artistName = args["artist_name"] as! String
let filePath = args["file_path"] as? String ?? ""
let response = GobackendGetLyricsLRC(spotifyId, trackName, artistName, filePath, &error)
let durationMs = args["duration_ms"] as? Int64 ?? 0
let response = GobackendGetLyricsLRC(spotifyId, trackName, artistName, filePath, durationMs, &error)
if let error = error { throw error }
return response
@@ -225,6 +248,13 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
case "getDeezerExtendedMetadata":
let args = call.arguments as! [String: Any]
let trackId = args["track_id"] as! String
let response = GobackendGetDeezerExtendedMetadata(trackId, &error)
if let error = error { throw error }
return response
case "convertSpotifyToDeezer":
let args = call.arguments as! [String: Any]
let resourceType = args["resource_type"] as! String
@@ -240,6 +270,43 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
case "checkAvailabilityFromDeezerID":
let args = call.arguments as! [String: Any]
let deezerTrackId = args["deezer_track_id"] as! String
let response = GobackendCheckAvailabilityFromDeezerID(deezerTrackId, &error)
if let error = error { throw error }
return response
case "checkAvailabilityByPlatformID":
let args = call.arguments as! [String: Any]
let platform = args["platform"] as! String
let entityType = args["entity_type"] as! String
let entityId = args["entity_id"] as! String
let response = GobackendCheckAvailabilityByPlatformID(platform, entityType, entityId, &error)
if let error = error { throw error }
return response
case "getSpotifyIDFromDeezerTrack":
let args = call.arguments as! [String: Any]
let deezerTrackId = args["deezer_track_id"] as! String
let response = GobackendGetSpotifyIDFromDeezerTrack(deezerTrackId, &error)
if let error = error { throw error }
return response
case "getTidalURLFromDeezerTrack":
let args = call.arguments as! [String: Any]
let deezerTrackId = args["deezer_track_id"] as! String
let response = GobackendGetTidalURLFromDeezerTrack(deezerTrackId, &error)
if let error = error { throw error }
return response
case "getAmazonURLFromDeezerTrack":
let args = call.arguments as! [String: Any]
let deezerTrackId = args["deezer_track_id"] as! String
let response = GobackendGetAmazonURLFromDeezerTrack(deezerTrackId, &error)
if let error = error { throw error }
return response
case "preWarmTrackCache":
let args = call.arguments as! [String: Any]
let tracksJson = args["tracks"] as! String
@@ -373,6 +440,14 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return nil
case "invokeExtensionAction":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
let actionName = args["action"] as! String
let response = GobackendInvokeExtensionActionJSON(extensionId, actionName, &error)
if let error = error { throw error }
return response
case "searchTracksWithExtensions":
let args = call.arguments as! [String: Any]
let query = args["query"] as! String
@@ -387,6 +462,14 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
case "enrichTrackWithExtension":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
let trackJson = args["track"] as? String ?? "{}"
let response = GobackendEnrichTrackWithExtensionJSON(extensionId, trackJson, &error)
if let error = error { throw error }
return response
case "removeExtension":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
@@ -588,6 +671,21 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return nil
// Extension Home Feed API
case "getExtensionHomeFeed":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
let response = GobackendGetExtensionHomeFeedJSON(extensionId, &error)
if let error = error { throw error }
return response
case "getExtensionBrowseCategories":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
let response = GobackendGetExtensionBrowseCategoriesJSON(extensionId, &error)
if let error = error { throw error }
return response
default:
throw NSError(
domain: "SpotiFLAC",
+7 -5
View File
@@ -9,7 +9,6 @@ import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart';
import 'package:spotiflac_android/l10n/app_localizations.dart';
final _routerProvider = Provider<GoRouter>((ref) {
// Only watch isFirstLaunch to prevent router rebuild on other settings changes
final isFirstLaunch = ref.watch(settingsProvider.select((s) => s.isFirstLaunch));
return GoRouter(
@@ -35,10 +34,14 @@ class SpotiFLACApp extends ConsumerWidget {
final router = ref.watch(_routerProvider);
final localeString = ref.watch(settingsProvider.select((s) => s.locale));
// Convert locale string to Locale object
Locale? locale;
if (localeString != 'system') {
locale = Locale(localeString);
if (localeString.contains('_')) {
final parts = localeString.split('_');
locale = Locale(parts[0], parts[1]);
} else {
locale = Locale(localeString);
}
}
return DynamicColorWrapper(
@@ -52,8 +55,7 @@ class SpotiFLACApp extends ConsumerWidget {
themeAnimationDuration: const Duration(milliseconds: 300),
themeAnimationCurve: Curves.easeInOut,
routerConfig: router,
// Localization
locale: locale, // null = follow system
locale: locale,
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
+2 -2
View File
@@ -1,8 +1,8 @@
/// App version and info constants
/// Update version here only - all other files will reference this
class AppInfo {
static const String version = '3.1.0';
static const String buildNumber = '59';
static const String version = '3.2.0';
static const String buildNumber = '63';
static const String fullVersion = '$version+$buildNumber';
+281
View File
@@ -16,6 +16,7 @@ import 'app_localizations_ko.dart';
import 'app_localizations_nl.dart';
import 'app_localizations_pt.dart';
import 'app_localizations_ru.dart';
import 'app_localizations_tr.dart';
import 'app_localizations_zh.dart';
// ignore_for_file: type=lint
@@ -107,6 +108,7 @@ abstract class AppLocalizations {
Locale('de'),
Locale('en'),
Locale('es'),
Locale('es', 'ES'),
Locale('fr'),
Locale('hi'),
Locale('id'),
@@ -114,7 +116,9 @@ abstract class AppLocalizations {
Locale('ko'),
Locale('nl'),
Locale('pt'),
Locale('pt', 'PT'),
Locale('ru'),
Locale('tr'),
Locale('zh'),
Locale('zh', 'CN'),
Locale('zh', 'TW'),
@@ -276,6 +280,12 @@ abstract class AppLocalizations {
/// **'Single track downloads will appear here'**
String get historyNoSinglesSubtitle;
/// Search bar placeholder in history
///
/// In en, this message translates to:
/// **'Search history...'**
String get historySearchHint;
/// Settings screen title
///
/// In en, this message translates to:
@@ -816,6 +826,12 @@ abstract class AppLocalizations {
/// **'The talented artist who created our beautiful app logo!'**
String get aboutLogoArtist;
/// Section for translators
///
/// In en, this message translates to:
/// **'Translators'**
String get aboutTranslators;
/// Section for special thanks
///
/// In en, this message translates to:
@@ -864,6 +880,36 @@ abstract class AppLocalizations {
/// **'Suggest new features for the app'**
String get aboutFeatureRequestSubtitle;
/// Link to Telegram channel
///
/// In en, this message translates to:
/// **'Telegram Channel'**
String get aboutTelegramChannel;
/// Subtitle for Telegram channel
///
/// In en, this message translates to:
/// **'Announcements and updates'**
String get aboutTelegramChannelSubtitle;
/// Link to Telegram chat group
///
/// In en, this message translates to:
/// **'Telegram Community'**
String get aboutTelegramChat;
/// Subtitle for Telegram chat
///
/// In en, this message translates to:
/// **'Chat with other users'**
String get aboutTelegramChatSubtitle;
/// Section for social links
///
/// In en, this message translates to:
/// **'Social'**
String get aboutSocial;
/// Section for support/donation links
///
/// In en, this message translates to:
@@ -1680,6 +1726,12 @@ abstract class AppLocalizations {
/// **'Found {count} tracks in CSV. Add them to download queue?'**
String dialogImportPlaylistMessage(int count);
/// Label shown in quality picker for CSV import
///
/// In en, this message translates to:
/// **'{count} tracks from CSV'**
String csvImportTracks(int count);
/// Snackbar - track added to download queue
///
/// In en, this message translates to:
@@ -2604,6 +2656,60 @@ abstract class AppLocalizations {
/// **'File Settings'**
String get sectionFileSettings;
/// Settings section header
///
/// In en, this message translates to:
/// **'Lyrics'**
String get sectionLyrics;
/// Setting - how to save lyrics
///
/// In en, this message translates to:
/// **'Lyrics Mode'**
String get lyricsMode;
/// Lyrics mode picker description
///
/// In en, this message translates to:
/// **'Choose how lyrics are saved with your downloads'**
String get lyricsModeDescription;
/// Lyrics mode option - embed in audio file
///
/// In en, this message translates to:
/// **'Embed in file'**
String get lyricsModeEmbed;
/// Subtitle for embed option
///
/// In en, this message translates to:
/// **'Lyrics stored inside FLAC metadata'**
String get lyricsModeEmbedSubtitle;
/// Lyrics mode option - separate LRC file
///
/// In en, this message translates to:
/// **'External .lrc file'**
String get lyricsModeExternal;
/// Subtitle for external option
///
/// In en, this message translates to:
/// **'Separate .lrc file for players like Samsung Music'**
String get lyricsModeExternalSubtitle;
/// Lyrics mode option - embed and external
///
/// In en, this message translates to:
/// **'Both'**
String get lyricsModeBoth;
/// Subtitle for both option
///
/// In en, this message translates to:
/// **'Embed and save .lrc file'**
String get lyricsModeBothSubtitle;
/// Settings section header
///
/// In en, this message translates to:
@@ -2808,6 +2914,24 @@ abstract class AppLocalizations {
/// **'Release date'**
String get trackReleaseDate;
/// Metadata label - music genre
///
/// In en, this message translates to:
/// **'Genre'**
String get trackGenre;
/// Metadata label - record label
///
/// In en, this message translates to:
/// **'Label'**
String get trackLabel;
/// Metadata label - copyright information
///
/// In en, this message translates to:
/// **'Copyright'**
String get trackCopyright;
/// Metadata label - download date
///
/// In en, this message translates to:
@@ -3252,6 +3376,36 @@ abstract class AppLocalizations {
/// **'24-bit / up to 192kHz'**
String get qualityHiResFlacMaxSubtitle;
/// Quality option - MP3 lossy format
///
/// In en, this message translates to:
/// **'MP3'**
String get qualityMp3;
/// Technical spec for MP3
///
/// In en, this message translates to:
/// **'320kbps (converted from FLAC)'**
String get qualityMp3Subtitle;
/// Setting - enable MP3 quality option
///
/// In en, this message translates to:
/// **'Enable MP3 Option'**
String get enableMp3Option;
/// Subtitle when MP3 is enabled
///
/// In en, this message translates to:
/// **'MP3 quality option is available'**
String get enableMp3OptionSubtitleOn;
/// Subtitle when MP3 is disabled
///
/// In en, this message translates to:
/// **'Downloads FLAC then converts to 320kbps MP3'**
String get enableMp3OptionSubtitleOff;
/// Note about quality availability
///
/// In en, this message translates to:
@@ -3588,6 +3742,12 @@ abstract class AppLocalizations {
/// **'Select tracks to delete'**
String get downloadedAlbumSelectToDelete;
/// Header for disc separator in multi-disc albums
///
/// In en, this message translates to:
/// **'Disc {discNumber}'**
String downloadedAlbumDiscHeader(int discNumber);
/// Extension capability - utility functions
///
/// In en, this message translates to:
@@ -3629,6 +3789,108 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Error: {message}'**
String errorGeneric(String message);
/// Button - download artist discography
///
/// In en, this message translates to:
/// **'Download Discography'**
String get discographyDownload;
/// Option - download entire discography
///
/// In en, this message translates to:
/// **'Download All'**
String get discographyDownloadAll;
/// Subtitle showing total tracks and albums
///
/// In en, this message translates to:
/// **'{count} tracks from {albumCount} releases'**
String discographyDownloadAllSubtitle(int count, int albumCount);
/// Option - download only albums
///
/// In en, this message translates to:
/// **'Albums Only'**
String get discographyAlbumsOnly;
/// Subtitle showing album tracks count
///
/// In en, this message translates to:
/// **'{count} tracks from {albumCount} albums'**
String discographyAlbumsOnlySubtitle(int count, int albumCount);
/// Option - download only singles
///
/// In en, this message translates to:
/// **'Singles & EPs Only'**
String get discographySinglesOnly;
/// Subtitle showing singles tracks count
///
/// In en, this message translates to:
/// **'{count} tracks from {albumCount} singles'**
String discographySinglesOnlySubtitle(int count, int albumCount);
/// Option - manually select albums to download
///
/// In en, this message translates to:
/// **'Select Albums...'**
String get discographySelectAlbums;
/// Subtitle for select albums option
///
/// In en, this message translates to:
/// **'Choose specific albums or singles'**
String get discographySelectAlbumsSubtitle;
/// Progress - fetching album tracks
///
/// In en, this message translates to:
/// **'Fetching tracks...'**
String get discographyFetchingTracks;
/// Progress - fetching specific album
///
/// In en, this message translates to:
/// **'Fetching {current} of {total}...'**
String discographyFetchingAlbum(int current, int total);
/// Selection count badge
///
/// In en, this message translates to:
/// **'{count} selected'**
String discographySelectedCount(int count);
/// Button - download selected albums
///
/// In en, this message translates to:
/// **'Download Selected'**
String get discographyDownloadSelected;
/// Snackbar - tracks added from discography
///
/// In en, this message translates to:
/// **'Added {count} tracks to queue'**
String discographyAddedToQueue(int count);
/// Snackbar - with skipped tracks count
///
/// In en, this message translates to:
/// **'{added} added, {skipped} already downloaded'**
String discographySkippedDownloaded(int added, int skipped);
/// Error - no albums found for artist
///
/// In en, this message translates to:
/// **'No albums available'**
String get discographyNoAlbums;
/// Error - some albums failed to load
///
/// In en, this message translates to:
/// **'Failed to fetch some albums'**
String get discographyFailedToFetch;
}
class _AppLocalizationsDelegate
@@ -3653,6 +3915,7 @@ class _AppLocalizationsDelegate
'nl',
'pt',
'ru',
'tr',
'zh',
].contains(locale.languageCode);
@@ -3663,6 +3926,22 @@ class _AppLocalizationsDelegate
AppLocalizations lookupAppLocalizations(Locale locale) {
// Lookup logic when language+country codes are specified.
switch (locale.languageCode) {
case 'es':
{
switch (locale.countryCode) {
case 'ES':
return AppLocalizationsEsEs();
}
break;
}
case 'pt':
{
switch (locale.countryCode) {
case 'PT':
return AppLocalizationsPtPt();
}
break;
}
case 'zh':
{
switch (locale.countryCode) {
@@ -3699,6 +3978,8 @@ AppLocalizations lookupAppLocalizations(Locale locale) {
return AppLocalizationsPt();
case 'ru':
return AppLocalizationsRu();
case 'tr':
return AppLocalizationsTr();
case 'zh':
return AppLocalizationsZh();
}
+287 -123
View File
@@ -13,56 +13,57 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get appDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
'Laden Sie Spotify-Titel in verlustfreier Qualität von Tidal, Qobuz und Amazon Music herunter.';
@override
String get navHome => 'Home';
String get navHome => 'Startseite';
@override
String get navHistory => 'History';
String get navHistory => 'Verlauf';
@override
String get navSettings => 'Settings';
String get navSettings => 'Einstellungen';
@override
String get navStore => 'Store';
@override
String get homeTitle => 'Home';
String get homeTitle => 'Startseite';
@override
String get homeSearchHint => 'Paste Spotify URL or search...';
String get homeSearchHint => 'Spotify-URL einfügen oder suchen...';
@override
String homeSearchHintExtension(String extensionName) {
return 'Search with $extensionName...';
return 'Mit $extensionName suchen...';
}
@override
String get homeSubtitle => 'Paste a Spotify link or search by name';
String get homeSubtitle => 'Spotify-Link einfügen oder nach Namen suchen';
@override
String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs';
String get homeSupports =>
'Unterstützt: Titel, Album, Playlist, Künstler-URLs';
@override
String get homeRecent => 'Recent';
String get homeRecent => 'Zuletzt';
@override
String get historyTitle => 'History';
String get historyTitle => 'Verlauf';
@override
String historyDownloading(int count) {
return 'Downloading ($count)';
return 'Wird heruntergeladen ($count)';
}
@override
String get historyDownloaded => 'Downloaded';
String get historyDownloaded => 'Heruntergeladen';
@override
String get historyFilterAll => 'All';
String get historyFilterAll => 'Alle';
@override
String get historyFilterAlbums => 'Albums';
String get historyFilterAlbums => 'Alben';
@override
String get historyFilterSingles => 'Singles';
@@ -72,8 +73,8 @@ class AppLocalizationsDe extends AppLocalizations {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks',
one: '1 track',
other: '$count Titel',
one: '1 Titel',
);
return '$_temp0';
}
@@ -83,93 +84,98 @@ class AppLocalizationsDe extends AppLocalizations {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count albums',
one: '1 album',
other: '$count Alben',
one: '1 Album',
);
return '$_temp0';
}
@override
String get historyNoDownloads => 'No download history';
String get historyNoDownloads => 'Kein Download-Verlauf';
@override
String get historyNoDownloadsSubtitle => 'Downloaded tracks will appear here';
String get historyNoDownloadsSubtitle =>
'Heruntergeladene Titel werden hier angezeigt';
@override
String get historyNoAlbums => 'No album downloads';
String get historyNoAlbums => 'Keine Album-Downloads';
@override
String get historyNoAlbumsSubtitle =>
'Download multiple tracks from an album to see them here';
'Laden Sie mehrere Titel eines Albums herunter, um sie hier zu sehen';
@override
String get historyNoSingles => 'No single downloads';
String get historyNoSingles => 'Keine Einzel-Downloads';
@override
String get historyNoSinglesSubtitle =>
'Single track downloads will appear here';
'Einzelne Titel-Downloads werden hier angezeigt';
@override
String get settingsTitle => 'Settings';
String get historySearchHint => 'Search history...';
@override
String get settingsDownload => 'Download';
String get settingsTitle => 'Einstellungen';
@override
String get settingsAppearance => 'Appearance';
String get settingsDownload => 'Herunterladen';
@override
String get settingsOptions => 'Options';
String get settingsAppearance => 'Erscheinungsbild';
@override
String get settingsExtensions => 'Extensions';
String get settingsOptions => 'Optionen';
@override
String get settingsAbout => 'About';
String get settingsExtensions => 'Erweiterungen';
@override
String get downloadTitle => 'Download';
String get settingsAbout => 'Über';
@override
String get downloadLocation => 'Download Location';
String get downloadTitle => 'Herunterladen';
@override
String get downloadLocationSubtitle => 'Choose where to save files';
String get downloadLocation => 'Download-Speicherort';
@override
String get downloadLocationDefault => 'Default location';
String get downloadLocationSubtitle =>
'Wählen Sie den Speicherort für Dateien';
@override
String get downloadDefaultService => 'Default Service';
String get downloadLocationDefault => 'Standard-Speicherort';
@override
String get downloadDefaultServiceSubtitle => 'Service used for downloads';
String get downloadDefaultService => 'Standard-Dienst';
@override
String get downloadDefaultQuality => 'Default Quality';
String get downloadDefaultServiceSubtitle => 'Dienst für Downloads';
@override
String get downloadAskQuality => 'Ask Quality Before Download';
String get downloadDefaultQuality => 'Standard-Qualität';
@override
String get downloadAskQuality => 'Qualität vor Download abfragen';
@override
String get downloadAskQualitySubtitle =>
'Show quality picker for each download';
'Qualitätsauswahl für jeden Download anzeigen';
@override
String get downloadFilenameFormat => 'Filename Format';
String get downloadFilenameFormat => 'Dateinamenformat';
@override
String get downloadFolderOrganization => 'Folder Organization';
String get downloadFolderOrganization => 'Ordnerstruktur';
@override
String get downloadSeparateSingles => 'Separate Singles';
String get downloadSeparateSingles => 'Singles trennen';
@override
String get downloadSeparateSinglesSubtitle =>
'Put single tracks in a separate folder';
'Einzelne Titel in separatem Ordner speichern';
@override
String get qualityBest => 'Best Available';
String get qualityBest => 'Beste Qualität';
@override
String get qualityFlac => 'FLAC';
@@ -181,179 +187,186 @@ class AppLocalizationsDe extends AppLocalizations {
String get quality128 => '128 kbps';
@override
String get appearanceTitle => 'Appearance';
String get appearanceTitle => 'Erscheinungsbild';
@override
String get appearanceTheme => 'Theme';
String get appearanceTheme => 'Design';
@override
String get appearanceThemeSystem => 'System';
@override
String get appearanceThemeLight => 'Light';
String get appearanceThemeLight => 'Hell';
@override
String get appearanceThemeDark => 'Dark';
String get appearanceThemeDark => 'Dunkel';
@override
String get appearanceDynamicColor => 'Dynamic Color';
String get appearanceDynamicColor => 'Dynamische Farben';
@override
String get appearanceDynamicColorSubtitle => 'Use colors from your wallpaper';
String get appearanceDynamicColorSubtitle =>
'Farben von Ihrem Hintergrundbild verwenden';
@override
String get appearanceAccentColor => 'Accent Color';
String get appearanceAccentColor => 'Akzentfarbe';
@override
String get appearanceHistoryView => 'History View';
String get appearanceHistoryView => 'Verlaufsansicht';
@override
String get appearanceHistoryViewList => 'List';
String get appearanceHistoryViewList => 'Liste';
@override
String get appearanceHistoryViewGrid => 'Grid';
String get appearanceHistoryViewGrid => 'Raster';
@override
String get optionsTitle => 'Options';
String get optionsTitle => 'Optionen';
@override
String get optionsSearchSource => 'Search Source';
String get optionsSearchSource => 'Suchquelle';
@override
String get optionsPrimaryProvider => 'Primary Provider';
String get optionsPrimaryProvider => 'Primärer Anbieter';
@override
String get optionsPrimaryProviderSubtitle =>
'Service used when searching by track name.';
'Dienst für die Suche nach Titelnamen.';
@override
String optionsUsingExtension(String extensionName) {
return 'Using extension: $extensionName';
return 'Erweiterung verwenden: $extensionName';
}
@override
String get optionsSwitchBack =>
'Tap Deezer or Spotify to switch back from extension';
'Tippen Sie auf Deezer oder Spotify, um von der Erweiterung zurückzuwechseln';
@override
String get optionsAutoFallback => 'Auto Fallback';
String get optionsAutoFallback => 'Automatischer Fallback';
@override
String get optionsAutoFallbackSubtitle =>
'Try other services if download fails';
'Andere Dienste versuchen, wenn Download fehlschlägt';
@override
String get optionsUseExtensionProviders => 'Use Extension Providers';
String get optionsUseExtensionProviders => 'Erweiterungs-Anbieter verwenden';
@override
String get optionsUseExtensionProvidersOn => 'Extensions will be tried first';
String get optionsUseExtensionProvidersOn =>
'Erweiterungen werden zuerst versucht';
@override
String get optionsUseExtensionProvidersOff => 'Using built-in providers only';
String get optionsUseExtensionProvidersOff =>
'Nur integrierte Anbieter verwenden';
@override
String get optionsEmbedLyrics => 'Embed Lyrics';
String get optionsEmbedLyrics => 'Liedtexte einbetten';
@override
String get optionsEmbedLyricsSubtitle =>
'Embed synced lyrics into FLAC files';
'Synchronisierte Liedtexte in FLAC-Dateien einbetten';
@override
String get optionsMaxQualityCover => 'Max Quality Cover';
String get optionsMaxQualityCover => 'Maximale Cover-Qualität';
@override
String get optionsMaxQualityCoverSubtitle =>
'Download highest resolution cover art';
'Cover in höchster Auflösung herunterladen';
@override
String get optionsConcurrentDownloads => 'Concurrent Downloads';
String get optionsConcurrentDownloads => 'Parallele Downloads';
@override
String get optionsConcurrentSequential => 'Sequential (1 at a time)';
String get optionsConcurrentSequential => 'Sequentiell (1 gleichzeitig)';
@override
String optionsConcurrentParallel(int count) {
return '$count parallel downloads';
return '$count parallele Downloads';
}
@override
String get optionsConcurrentWarning =>
'Parallel downloads may trigger rate limiting';
'Parallele Downloads können Ratenlimitierung auslösen';
@override
String get optionsExtensionStore => 'Extension Store';
String get optionsExtensionStore => 'Erweiterungs-Store';
@override
String get optionsExtensionStoreSubtitle => 'Show Store tab in navigation';
String get optionsExtensionStoreSubtitle =>
'Store-Tab in Navigation anzeigen';
@override
String get optionsCheckUpdates => 'Check for Updates';
String get optionsCheckUpdates => 'Nach Updates suchen';
@override
String get optionsCheckUpdatesSubtitle =>
'Notify when new version is available';
'Benachrichtigen, wenn neue Version verfügbar';
@override
String get optionsUpdateChannel => 'Update Channel';
String get optionsUpdateChannel => 'Update-Kanal';
@override
String get optionsUpdateChannelStable => 'Stable releases only';
String get optionsUpdateChannelStable => 'Nur stabile Versionen';
@override
String get optionsUpdateChannelPreview => 'Get preview releases';
String get optionsUpdateChannelPreview => 'Vorschau-Versionen erhalten';
@override
String get optionsUpdateChannelWarning =>
'Preview may contain bugs or incomplete features';
'Vorschau kann Fehler oder unvollständige Funktionen enthalten';
@override
String get optionsClearHistory => 'Clear Download History';
String get optionsClearHistory => 'Download-Verlauf löschen';
@override
String get optionsClearHistorySubtitle =>
'Remove all downloaded tracks from history';
'Alle heruntergeladenen Titel aus dem Verlauf entfernen';
@override
String get optionsDetailedLogging => 'Detailed Logging';
String get optionsDetailedLogging => 'Detaillierte Protokollierung';
@override
String get optionsDetailedLoggingOn => 'Detailed logs are being recorded';
String get optionsDetailedLoggingOn =>
'Detaillierte Protokolle werden aufgezeichnet';
@override
String get optionsDetailedLoggingOff => 'Enable for bug reports';
String get optionsDetailedLoggingOff => 'Für Fehlerberichte aktivieren';
@override
String get optionsSpotifyCredentials => 'Spotify Credentials';
String get optionsSpotifyCredentials => 'Spotify-Anmeldedaten';
@override
String optionsSpotifyCredentialsConfigured(String clientId) {
return 'Client ID: $clientId...';
return 'Client-ID: $clientId...';
}
@override
String get optionsSpotifyCredentialsRequired => 'Required - tap to configure';
String get optionsSpotifyCredentialsRequired =>
'Erforderlich - zum Konfigurieren tippen';
@override
String get optionsSpotifyWarning =>
'Spotify requires your own API credentials. Get them free from developer.spotify.com';
'Spotify erfordert eigene API-Anmeldedaten. Kostenlos erhältlich auf developer.spotify.com';
@override
String get extensionsTitle => 'Extensions';
String get extensionsTitle => 'Erweiterungen';
@override
String get extensionsInstalled => 'Installed Extensions';
String get extensionsInstalled => 'Installierte Erweiterungen';
@override
String get extensionsNone => 'No extensions installed';
String get extensionsNone => 'Keine Erweiterungen installiert';
@override
String get extensionsNoneSubtitle => 'Install extensions from the Store tab';
String get extensionsNoneSubtitle =>
'Erweiterungen aus dem Store-Tab installieren';
@override
String get extensionsEnabled => 'Enabled';
String get extensionsEnabled => 'Aktiviert';
@override
String get extensionsDisabled => 'Disabled';
String get extensionsDisabled => 'Deaktiviert';
@override
String extensionsVersion(String version) {
@@ -362,78 +375,99 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String extensionsAuthor(String author) {
return 'by $author';
return 'von $author';
}
@override
String get extensionsUninstall => 'Uninstall';
String get extensionsUninstall => 'Deinstallieren';
@override
String get extensionsSetAsSearch => 'Set as Search Provider';
String get extensionsSetAsSearch => 'Als Suchanbieter festlegen';
@override
String get storeTitle => 'Extension Store';
String get storeTitle => 'Erweiterungs-Store';
@override
String get storeSearch => 'Search extensions...';
String get storeSearch => 'Erweiterungen suchen...';
@override
String get storeInstall => 'Install';
String get storeInstall => 'Installieren';
@override
String get storeInstalled => 'Installed';
String get storeInstalled => 'Installiert';
@override
String get storeUpdate => 'Update';
String get storeUpdate => 'Aktualisieren';
@override
String get aboutTitle => 'About';
String get aboutTitle => 'Über';
@override
String get aboutContributors => 'Contributors';
String get aboutContributors => 'Mitwirkende';
@override
String get aboutMobileDeveloper => 'Mobile version developer';
String get aboutMobileDeveloper => 'Mobile-Version Entwickler';
@override
String get aboutOriginalCreator => 'Creator of the original SpotiFLAC';
String get aboutOriginalCreator => 'Schöpfer des ursprünglichen SpotiFLAC';
@override
String get aboutLogoArtist =>
'The talented artist who created our beautiful app logo!';
'Der talentierte Künstler, der unser wunderschönes App-Logo entworfen hat!';
@override
String get aboutSpecialThanks => 'Special Thanks';
String get aboutTranslators => 'Translators';
@override
String get aboutSpecialThanks => 'Besonderer Dank';
@override
String get aboutLinks => 'Links';
@override
String get aboutMobileSource => 'Mobile source code';
String get aboutMobileSource => 'Mobiler Quellcode';
@override
String get aboutPCSource => 'PC source code';
String get aboutPCSource => 'PC Quellcode';
@override
String get aboutReportIssue => 'Report an issue';
String get aboutReportIssue => 'Problem melden';
@override
String get aboutReportIssueSubtitle => 'Report any problems you encounter';
String get aboutReportIssueSubtitle =>
'Melde jedes Problem, die dir auftreten';
@override
String get aboutFeatureRequest => 'Feature request';
String get aboutFeatureRequest => 'Feature vorschlagen';
@override
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
String get aboutFeatureRequestSubtitle =>
'Schlage neue Funktionen für die App vor';
@override
String get aboutTelegramChannel => 'Telegram Channel';
@override
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
@override
String get aboutTelegramChat => 'Telegram Community';
@override
String get aboutTelegramChatSubtitle => 'Chat with other users';
@override
String get aboutSocial => 'Social';
@override
String get aboutSupport => 'Support';
@override
String get aboutBuyMeCoffee => 'Buy me a coffee';
String get aboutBuyMeCoffee => 'Spendiere mir einen Kaffee';
@override
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
String get aboutBuyMeCoffeeSubtitle =>
'Unterstütze die Entwicklung auf Ko-fi';
@override
String get aboutApp => 'App';
@@ -443,25 +477,25 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get aboutBinimumDesc =>
'The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn\'t exist!';
'Der Schöpfer der QQDL & HiFi API. Ohne diese API gäbe es keine Tidal-Downloads!';
@override
String get aboutSachinsenalDesc =>
'The original HiFi project creator. The foundation of Tidal integration!';
'Der ursprüngliche Entwickler des HiFi-Projekts. Die Grundlage der Tidal-Integration!';
@override
String get aboutDoubleDouble => 'DoubleDouble';
@override
String get aboutDoubleDoubleDesc =>
'Amazing API for Amazon Music downloads. Thank you for making it free!';
'Wundervolle API für Amazon Music Downloads.\nVielen Dank, dass Sie sie kostenlos zur Verfügung stellen!';
@override
String get aboutDabMusic => 'DAB Music';
@override
String get aboutDabMusicDesc =>
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
'Die beste Qobuz-Streaming-API. Hi-Res-Downloads wären ohne diese nicht möglich!';
@override
String get aboutAppDescription =>
@@ -894,6 +928,11 @@ class AppLocalizationsDe extends AppLocalizations {
return 'Found $count tracks in CSV. Add them to download queue?';
}
@override
String csvImportTracks(int count) {
return '$count tracks from CSV';
}
@override
String snackbarAddedToQueue(String trackName) {
return 'Added \"$trackName\" to queue';
@@ -1427,6 +1466,35 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get sectionFileSettings => 'File Settings';
@override
String get sectionLyrics => 'Lyrics';
@override
String get lyricsMode => 'Lyrics Mode';
@override
String get lyricsModeDescription =>
'Choose how lyrics are saved with your downloads';
@override
String get lyricsModeEmbed => 'Embed in file';
@override
String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata';
@override
String get lyricsModeExternal => 'External .lrc file';
@override
String get lyricsModeExternalSubtitle =>
'Separate .lrc file for players like Samsung Music';
@override
String get lyricsModeBoth => 'Both';
@override
String get lyricsModeBothSubtitle => 'Embed and save .lrc file';
@override
String get sectionColor => 'Color';
@@ -1539,6 +1607,15 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get trackReleaseDate => 'Release date';
@override
String get trackGenre => 'Genre';
@override
String get trackLabel => 'Label';
@override
String get trackCopyright => 'Copyright';
@override
String get trackDownloaded => 'Downloaded';
@@ -1782,6 +1859,22 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override
String get qualityMp3 => 'MP3';
@override
String get qualityMp3Subtitle => '320kbps (converted from FLAC)';
@override
String get enableMp3Option => 'Enable MP3 Option';
@override
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available';
@override
String get enableMp3OptionSubtitleOff =>
'Downloads FLAC then converts to 320kbps MP3';
@override
String get qualityNote =>
'Actual quality depends on track availability from the service';
@@ -1973,6 +2066,11 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
@override
String downloadedAlbumDiscHeader(int discNumber) {
return 'Disc $discNumber';
}
@override
String get utilityFunctions => 'Utility Functions';
@@ -1997,4 +2095,70 @@ class AppLocalizationsDe extends AppLocalizations {
String errorGeneric(String message) {
return 'Error: $message';
}
@override
String get discographyDownload => 'Download Discography';
@override
String get discographyDownloadAll => 'Download All';
@override
String discographyDownloadAllSubtitle(int count, int albumCount) {
return '$count tracks from $albumCount releases';
}
@override
String get discographyAlbumsOnly => 'Albums Only';
@override
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
return '$count tracks from $albumCount albums';
}
@override
String get discographySinglesOnly => 'Singles & EPs Only';
@override
String discographySinglesOnlySubtitle(int count, int albumCount) {
return '$count tracks from $albumCount singles';
}
@override
String get discographySelectAlbums => 'Select Albums...';
@override
String get discographySelectAlbumsSubtitle =>
'Choose specific albums or singles';
@override
String get discographyFetchingTracks => 'Fetching tracks...';
@override
String discographyFetchingAlbum(int current, int total) {
return 'Fetching $current of $total...';
}
@override
String discographySelectedCount(int count) {
return '$count selected';
}
@override
String get discographyDownloadSelected => 'Download Selected';
@override
String discographyAddedToQueue(int count) {
return 'Added $count tracks to queue';
}
@override
String discographySkippedDownloaded(int added, int skipped) {
return '$added added, $skipped already downloaded';
}
@override
String get discographyNoAlbums => 'No albums available';
@override
String get discographyFailedToFetch => 'Failed to fetch some albums';
}
+151
View File
@@ -109,6 +109,9 @@ class AppLocalizationsEn extends AppLocalizations {
String get historyNoSinglesSubtitle =>
'Single track downloads will appear here';
@override
String get historySearchHint => 'Search history...';
@override
String get settingsTitle => 'Settings';
@@ -402,6 +405,9 @@ class AppLocalizationsEn extends AppLocalizations {
String get aboutLogoArtist =>
'The talented artist who created our beautiful app logo!';
@override
String get aboutTranslators => 'Translators';
@override
String get aboutSpecialThanks => 'Special Thanks';
@@ -426,6 +432,21 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
@override
String get aboutTelegramChannel => 'Telegram Channel';
@override
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
@override
String get aboutTelegramChat => 'Telegram Community';
@override
String get aboutTelegramChatSubtitle => 'Chat with other users';
@override
String get aboutSocial => 'Social';
@override
String get aboutSupport => 'Support';
@@ -894,6 +915,11 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Found $count tracks in CSV. Add them to download queue?';
}
@override
String csvImportTracks(int count) {
return '$count tracks from CSV';
}
@override
String snackbarAddedToQueue(String trackName) {
return 'Added \"$trackName\" to queue';
@@ -1427,6 +1453,35 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get sectionFileSettings => 'File Settings';
@override
String get sectionLyrics => 'Lyrics';
@override
String get lyricsMode => 'Lyrics Mode';
@override
String get lyricsModeDescription =>
'Choose how lyrics are saved with your downloads';
@override
String get lyricsModeEmbed => 'Embed in file';
@override
String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata';
@override
String get lyricsModeExternal => 'External .lrc file';
@override
String get lyricsModeExternalSubtitle =>
'Separate .lrc file for players like Samsung Music';
@override
String get lyricsModeBoth => 'Both';
@override
String get lyricsModeBothSubtitle => 'Embed and save .lrc file';
@override
String get sectionColor => 'Color';
@@ -1539,6 +1594,15 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get trackReleaseDate => 'Release date';
@override
String get trackGenre => 'Genre';
@override
String get trackLabel => 'Label';
@override
String get trackCopyright => 'Copyright';
@override
String get trackDownloaded => 'Downloaded';
@@ -1782,6 +1846,22 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override
String get qualityMp3 => 'MP3';
@override
String get qualityMp3Subtitle => '320kbps (converted from FLAC)';
@override
String get enableMp3Option => 'Enable MP3 Option';
@override
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available';
@override
String get enableMp3OptionSubtitleOff =>
'Downloads FLAC then converts to 320kbps MP3';
@override
String get qualityNote =>
'Actual quality depends on track availability from the service';
@@ -1973,6 +2053,11 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
@override
String downloadedAlbumDiscHeader(int discNumber) {
return 'Disc $discNumber';
}
@override
String get utilityFunctions => 'Utility Functions';
@@ -1997,4 +2082,70 @@ class AppLocalizationsEn extends AppLocalizations {
String errorGeneric(String message) {
return 'Error: $message';
}
@override
String get discographyDownload => 'Download Discography';
@override
String get discographyDownloadAll => 'Download All';
@override
String discographyDownloadAllSubtitle(int count, int albumCount) {
return '$count tracks from $albumCount releases';
}
@override
String get discographyAlbumsOnly => 'Albums Only';
@override
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
return '$count tracks from $albumCount albums';
}
@override
String get discographySinglesOnly => 'Singles & EPs Only';
@override
String discographySinglesOnlySubtitle(int count, int albumCount) {
return '$count tracks from $albumCount singles';
}
@override
String get discographySelectAlbums => 'Select Albums...';
@override
String get discographySelectAlbumsSubtitle =>
'Choose specific albums or singles';
@override
String get discographyFetchingTracks => 'Fetching tracks...';
@override
String discographyFetchingAlbum(int current, int total) {
return 'Fetching $current of $total...';
}
@override
String discographySelectedCount(int count) {
return '$count selected';
}
@override
String get discographyDownloadSelected => 'Download Selected';
@override
String discographyAddedToQueue(int count) {
return 'Added $count tracks to queue';
}
@override
String discographySkippedDownloaded(int added, int skipped) {
return '$added added, $skipped already downloaded';
}
@override
String get discographyNoAlbums => 'No albums available';
@override
String get discographyFailedToFetch => 'Failed to fetch some albums';
}
File diff suppressed because it is too large Load Diff
+151
View File
@@ -109,6 +109,9 @@ class AppLocalizationsFr extends AppLocalizations {
String get historyNoSinglesSubtitle =>
'Single track downloads will appear here';
@override
String get historySearchHint => 'Search history...';
@override
String get settingsTitle => 'Settings';
@@ -402,6 +405,9 @@ class AppLocalizationsFr extends AppLocalizations {
String get aboutLogoArtist =>
'The talented artist who created our beautiful app logo!';
@override
String get aboutTranslators => 'Translators';
@override
String get aboutSpecialThanks => 'Special Thanks';
@@ -426,6 +432,21 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
@override
String get aboutTelegramChannel => 'Telegram Channel';
@override
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
@override
String get aboutTelegramChat => 'Telegram Community';
@override
String get aboutTelegramChatSubtitle => 'Chat with other users';
@override
String get aboutSocial => 'Social';
@override
String get aboutSupport => 'Support';
@@ -894,6 +915,11 @@ class AppLocalizationsFr extends AppLocalizations {
return 'Found $count tracks in CSV. Add them to download queue?';
}
@override
String csvImportTracks(int count) {
return '$count tracks from CSV';
}
@override
String snackbarAddedToQueue(String trackName) {
return 'Added \"$trackName\" to queue';
@@ -1427,6 +1453,35 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get sectionFileSettings => 'File Settings';
@override
String get sectionLyrics => 'Lyrics';
@override
String get lyricsMode => 'Lyrics Mode';
@override
String get lyricsModeDescription =>
'Choose how lyrics are saved with your downloads';
@override
String get lyricsModeEmbed => 'Embed in file';
@override
String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata';
@override
String get lyricsModeExternal => 'External .lrc file';
@override
String get lyricsModeExternalSubtitle =>
'Separate .lrc file for players like Samsung Music';
@override
String get lyricsModeBoth => 'Both';
@override
String get lyricsModeBothSubtitle => 'Embed and save .lrc file';
@override
String get sectionColor => 'Color';
@@ -1539,6 +1594,15 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get trackReleaseDate => 'Release date';
@override
String get trackGenre => 'Genre';
@override
String get trackLabel => 'Label';
@override
String get trackCopyright => 'Copyright';
@override
String get trackDownloaded => 'Downloaded';
@@ -1782,6 +1846,22 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override
String get qualityMp3 => 'MP3';
@override
String get qualityMp3Subtitle => '320kbps (converted from FLAC)';
@override
String get enableMp3Option => 'Enable MP3 Option';
@override
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available';
@override
String get enableMp3OptionSubtitleOff =>
'Downloads FLAC then converts to 320kbps MP3';
@override
String get qualityNote =>
'Actual quality depends on track availability from the service';
@@ -1973,6 +2053,11 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
@override
String downloadedAlbumDiscHeader(int discNumber) {
return 'Disc $discNumber';
}
@override
String get utilityFunctions => 'Utility Functions';
@@ -1997,4 +2082,70 @@ class AppLocalizationsFr extends AppLocalizations {
String errorGeneric(String message) {
return 'Error: $message';
}
@override
String get discographyDownload => 'Download Discography';
@override
String get discographyDownloadAll => 'Download All';
@override
String discographyDownloadAllSubtitle(int count, int albumCount) {
return '$count tracks from $albumCount releases';
}
@override
String get discographyAlbumsOnly => 'Albums Only';
@override
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
return '$count tracks from $albumCount albums';
}
@override
String get discographySinglesOnly => 'Singles & EPs Only';
@override
String discographySinglesOnlySubtitle(int count, int albumCount) {
return '$count tracks from $albumCount singles';
}
@override
String get discographySelectAlbums => 'Select Albums...';
@override
String get discographySelectAlbumsSubtitle =>
'Choose specific albums or singles';
@override
String get discographyFetchingTracks => 'Fetching tracks...';
@override
String discographyFetchingAlbum(int current, int total) {
return 'Fetching $current of $total...';
}
@override
String discographySelectedCount(int count) {
return '$count selected';
}
@override
String get discographyDownloadSelected => 'Download Selected';
@override
String discographyAddedToQueue(int count) {
return 'Added $count tracks to queue';
}
@override
String discographySkippedDownloaded(int added, int skipped) {
return '$added added, $skipped already downloaded';
}
@override
String get discographyNoAlbums => 'No albums available';
@override
String get discographyFailedToFetch => 'Failed to fetch some albums';
}
+151
View File
@@ -109,6 +109,9 @@ class AppLocalizationsHi extends AppLocalizations {
String get historyNoSinglesSubtitle =>
'Single track downloads will appear here';
@override
String get historySearchHint => 'Search history...';
@override
String get settingsTitle => 'Settings';
@@ -402,6 +405,9 @@ class AppLocalizationsHi extends AppLocalizations {
String get aboutLogoArtist =>
'The talented artist who created our beautiful app logo!';
@override
String get aboutTranslators => 'Translators';
@override
String get aboutSpecialThanks => 'Special Thanks';
@@ -426,6 +432,21 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
@override
String get aboutTelegramChannel => 'Telegram Channel';
@override
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
@override
String get aboutTelegramChat => 'Telegram Community';
@override
String get aboutTelegramChatSubtitle => 'Chat with other users';
@override
String get aboutSocial => 'Social';
@override
String get aboutSupport => 'Support';
@@ -894,6 +915,11 @@ class AppLocalizationsHi extends AppLocalizations {
return 'Found $count tracks in CSV. Add them to download queue?';
}
@override
String csvImportTracks(int count) {
return '$count tracks from CSV';
}
@override
String snackbarAddedToQueue(String trackName) {
return 'Added \"$trackName\" to queue';
@@ -1427,6 +1453,35 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get sectionFileSettings => 'File Settings';
@override
String get sectionLyrics => 'Lyrics';
@override
String get lyricsMode => 'Lyrics Mode';
@override
String get lyricsModeDescription =>
'Choose how lyrics are saved with your downloads';
@override
String get lyricsModeEmbed => 'Embed in file';
@override
String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata';
@override
String get lyricsModeExternal => 'External .lrc file';
@override
String get lyricsModeExternalSubtitle =>
'Separate .lrc file for players like Samsung Music';
@override
String get lyricsModeBoth => 'Both';
@override
String get lyricsModeBothSubtitle => 'Embed and save .lrc file';
@override
String get sectionColor => 'Color';
@@ -1539,6 +1594,15 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get trackReleaseDate => 'Release date';
@override
String get trackGenre => 'Genre';
@override
String get trackLabel => 'Label';
@override
String get trackCopyright => 'Copyright';
@override
String get trackDownloaded => 'Downloaded';
@@ -1782,6 +1846,22 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override
String get qualityMp3 => 'MP3';
@override
String get qualityMp3Subtitle => '320kbps (converted from FLAC)';
@override
String get enableMp3Option => 'Enable MP3 Option';
@override
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available';
@override
String get enableMp3OptionSubtitleOff =>
'Downloads FLAC then converts to 320kbps MP3';
@override
String get qualityNote =>
'Actual quality depends on track availability from the service';
@@ -1973,6 +2053,11 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
@override
String downloadedAlbumDiscHeader(int discNumber) {
return 'Disc $discNumber';
}
@override
String get utilityFunctions => 'Utility Functions';
@@ -1997,4 +2082,70 @@ class AppLocalizationsHi extends AppLocalizations {
String errorGeneric(String message) {
return 'Error: $message';
}
@override
String get discographyDownload => 'Download Discography';
@override
String get discographyDownloadAll => 'Download All';
@override
String discographyDownloadAllSubtitle(int count, int albumCount) {
return '$count tracks from $albumCount releases';
}
@override
String get discographyAlbumsOnly => 'Albums Only';
@override
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
return '$count tracks from $albumCount albums';
}
@override
String get discographySinglesOnly => 'Singles & EPs Only';
@override
String discographySinglesOnlySubtitle(int count, int albumCount) {
return '$count tracks from $albumCount singles';
}
@override
String get discographySelectAlbums => 'Select Albums...';
@override
String get discographySelectAlbumsSubtitle =>
'Choose specific albums or singles';
@override
String get discographyFetchingTracks => 'Fetching tracks...';
@override
String discographyFetchingAlbum(int current, int total) {
return 'Fetching $current of $total...';
}
@override
String discographySelectedCount(int count) {
return '$count selected';
}
@override
String get discographyDownloadSelected => 'Download Selected';
@override
String discographyAddedToQueue(int count) {
return 'Added $count tracks to queue';
}
@override
String discographySkippedDownloaded(int added, int skipped) {
return '$added added, $skipped already downloaded';
}
@override
String get discographyNoAlbums => 'No albums available';
@override
String get discographyFailedToFetch => 'Failed to fetch some albums';
}
+151
View File
@@ -110,6 +110,9 @@ class AppLocalizationsId extends AppLocalizations {
String get historyNoSinglesSubtitle =>
'Unduhan lagu satuan akan muncul di sini';
@override
String get historySearchHint => 'Search history...';
@override
String get settingsTitle => 'Pengaturan';
@@ -406,6 +409,9 @@ class AppLocalizationsId extends AppLocalizations {
String get aboutLogoArtist =>
'Seniman berbakat yang membuat logo aplikasi kita yang indah!';
@override
String get aboutTranslators => 'Translators';
@override
String get aboutSpecialThanks => 'Terima Kasih Khusus';
@@ -431,6 +437,21 @@ class AppLocalizationsId extends AppLocalizations {
String get aboutFeatureRequestSubtitle =>
'Sarankan fitur baru untuk aplikasi';
@override
String get aboutTelegramChannel => 'Telegram Channel';
@override
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
@override
String get aboutTelegramChat => 'Telegram Community';
@override
String get aboutTelegramChatSubtitle => 'Chat with other users';
@override
String get aboutSocial => 'Social';
@override
String get aboutSupport => 'Dukungan';
@@ -900,6 +921,11 @@ class AppLocalizationsId extends AppLocalizations {
return 'Ditemukan $count lagu di CSV. Tambahkan ke antrian unduhan?';
}
@override
String csvImportTracks(int count) {
return '$count tracks from CSV';
}
@override
String snackbarAddedToQueue(String trackName) {
return 'Menambahkan \"$trackName\" ke antrian';
@@ -1437,6 +1463,35 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get sectionFileSettings => 'Pengaturan File';
@override
String get sectionLyrics => 'Lyrics';
@override
String get lyricsMode => 'Lyrics Mode';
@override
String get lyricsModeDescription =>
'Choose how lyrics are saved with your downloads';
@override
String get lyricsModeEmbed => 'Embed in file';
@override
String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata';
@override
String get lyricsModeExternal => 'External .lrc file';
@override
String get lyricsModeExternalSubtitle =>
'Separate .lrc file for players like Samsung Music';
@override
String get lyricsModeBoth => 'Both';
@override
String get lyricsModeBothSubtitle => 'Embed and save .lrc file';
@override
String get sectionColor => 'Warna';
@@ -1549,6 +1604,15 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get trackReleaseDate => 'Tanggal rilis';
@override
String get trackGenre => 'Genre';
@override
String get trackLabel => 'Label';
@override
String get trackCopyright => 'Copyright';
@override
String get trackDownloaded => 'Diunduh';
@@ -1794,6 +1858,22 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-bit / hingga 192kHz';
@override
String get qualityMp3 => 'MP3';
@override
String get qualityMp3Subtitle => '320kbps (konversi dari FLAC)';
@override
String get enableMp3Option => 'Aktifkan Opsi MP3';
@override
String get enableMp3OptionSubtitleOn => 'Opsi kualitas MP3 tersedia';
@override
String get enableMp3OptionSubtitleOff =>
'Unduh FLAC lalu konversi ke MP3 320kbps';
@override
String get qualityNote =>
'Kualitas sebenarnya tergantung ketersediaan lagu dari layanan';
@@ -1986,6 +2066,11 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get downloadedAlbumSelectToDelete => 'Pilih lagu untuk dihapus';
@override
String downloadedAlbumDiscHeader(int discNumber) {
return 'Disc $discNumber';
}
@override
String get utilityFunctions => 'Fungsi Utilitas';
@@ -2010,4 +2095,70 @@ class AppLocalizationsId extends AppLocalizations {
String errorGeneric(String message) {
return 'Error: $message';
}
@override
String get discographyDownload => 'Unduh Diskografi';
@override
String get discographyDownloadAll => 'Unduh Semua';
@override
String discographyDownloadAllSubtitle(int count, int albumCount) {
return '$count lagu dari $albumCount rilis';
}
@override
String get discographyAlbumsOnly => 'Album Saja';
@override
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
return '$count lagu dari $albumCount album';
}
@override
String get discographySinglesOnly => 'Single & EP Saja';
@override
String discographySinglesOnlySubtitle(int count, int albumCount) {
return '$count lagu dari $albumCount single';
}
@override
String get discographySelectAlbums => 'Pilih Album...';
@override
String get discographySelectAlbumsSubtitle =>
'Pilih album atau single tertentu';
@override
String get discographyFetchingTracks => 'Mengambil lagu...';
@override
String discographyFetchingAlbum(int current, int total) {
return 'Mengambil $current dari $total...';
}
@override
String discographySelectedCount(int count) {
return '$count dipilih';
}
@override
String get discographyDownloadSelected => 'Unduh yang Dipilih';
@override
String discographyAddedToQueue(int count) {
return 'Menambahkan $count lagu ke antrian';
}
@override
String discographySkippedDownloaded(int added, int skipped) {
return '$added ditambahkan, $skipped sudah diunduh';
}
@override
String get discographyNoAlbums => 'Tidak ada album tersedia';
@override
String get discographyFailedToFetch => 'Gagal mengambil beberapa album';
}
+278 -127
View File
@@ -16,19 +16,19 @@ class AppLocalizationsJa extends AppLocalizations {
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
@override
String get navHome => 'Home';
String get navHome => 'ホーム';
@override
String get navHistory => 'History';
String get navHistory => '履歴';
@override
String get navSettings => 'Settings';
String get navSettings => '設定';
@override
String get navStore => 'Store';
String get navStore => 'ストア';
@override
String get homeTitle => 'Home';
String get homeTitle => 'ホーム';
@override
String get homeSearchHint => 'Paste Spotify URL or search...';
@@ -52,20 +52,20 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String historyDownloading(int count) {
return 'Downloading ($count)';
return 'ダウンロード中 ($count)';
}
@override
String get historyDownloaded => 'Downloaded';
String get historyDownloaded => 'ダウンロード済み';
@override
String get historyFilterAll => 'All';
String get historyFilterAll => 'すべて';
@override
String get historyFilterAlbums => 'Albums';
String get historyFilterAlbums => 'アルバム';
@override
String get historyFilterSingles => 'Singles';
String get historyFilterSingles => 'シングル';
@override
String historyTracksCount(int count) {
@@ -110,25 +110,28 @@ class AppLocalizationsJa extends AppLocalizations {
'Single track downloads will appear here';
@override
String get settingsTitle => 'Settings';
String get historySearchHint => 'Search history...';
@override
String get settingsDownload => 'Download';
String get settingsTitle => '設定';
@override
String get settingsAppearance => 'Appearance';
String get settingsDownload => 'ダウンロード';
@override
String get settingsOptions => 'Options';
String get settingsAppearance => '外観';
@override
String get settingsExtensions => 'Extensions';
String get settingsOptions => 'オプション';
@override
String get settingsAbout => 'About';
String get settingsExtensions => '拡張';
@override
String get downloadTitle => 'Download';
String get settingsAbout => 'アプリについて';
@override
String get downloadTitle => 'ダウンロード';
@override
String get downloadLocation => 'Download Location';
@@ -137,16 +140,16 @@ class AppLocalizationsJa extends AppLocalizations {
String get downloadLocationSubtitle => 'Choose where to save files';
@override
String get downloadLocationDefault => 'Default location';
String get downloadLocationDefault => 'デフォルトの場所';
@override
String get downloadDefaultService => 'Default Service';
String get downloadDefaultService => 'デフォルトのサービス';
@override
String get downloadDefaultServiceSubtitle => 'Service used for downloads';
String get downloadDefaultServiceSubtitle => 'ダウンロードに使用したサービス';
@override
String get downloadDefaultQuality => 'Default Quality';
String get downloadDefaultQuality => 'デフォルトの品質';
@override
String get downloadAskQuality => 'Ask Quality Before Download';
@@ -156,7 +159,7 @@ class AppLocalizationsJa extends AppLocalizations {
'Show quality picker for each download';
@override
String get downloadFilenameFormat => 'Filename Format';
String get downloadFilenameFormat => 'ファイル名の形式';
@override
String get downloadFolderOrganization => 'Folder Organization';
@@ -181,46 +184,46 @@ class AppLocalizationsJa extends AppLocalizations {
String get quality128 => '128 kbps';
@override
String get appearanceTitle => 'Appearance';
String get appearanceTitle => '外観';
@override
String get appearanceTheme => 'Theme';
String get appearanceTheme => 'テーマ';
@override
String get appearanceThemeSystem => 'System';
String get appearanceThemeSystem => 'システム';
@override
String get appearanceThemeLight => 'Light';
String get appearanceThemeLight => 'ライト';
@override
String get appearanceThemeDark => 'Dark';
String get appearanceThemeDark => 'ダーク';
@override
String get appearanceDynamicColor => 'Dynamic Color';
String get appearanceDynamicColor => 'ダイナミックカラー';
@override
String get appearanceDynamicColorSubtitle => 'Use colors from your wallpaper';
@override
String get appearanceAccentColor => 'Accent Color';
String get appearanceAccentColor => 'アクセントカラー';
@override
String get appearanceHistoryView => 'History View';
String get appearanceHistoryView => '履歴の表示';
@override
String get appearanceHistoryViewList => 'List';
String get appearanceHistoryViewList => 'リスト';
@override
String get appearanceHistoryViewGrid => 'Grid';
String get appearanceHistoryViewGrid => 'グリッド';
@override
String get optionsTitle => 'Options';
String get optionsTitle => 'オプション';
@override
String get optionsSearchSource => 'Search Source';
String get optionsSearchSource => '検索ソース';
@override
String get optionsPrimaryProvider => 'Primary Provider';
String get optionsPrimaryProvider => 'プライマリーのプロバイダー';
@override
String get optionsPrimaryProviderSubtitle =>
@@ -228,7 +231,7 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String optionsUsingExtension(String extensionName) {
return 'Using extension: $extensionName';
return '拡張の使用: $extensionName';
}
@override
@@ -243,23 +246,23 @@ class AppLocalizationsJa extends AppLocalizations {
'Try other services if download fails';
@override
String get optionsUseExtensionProviders => 'Use Extension Providers';
String get optionsUseExtensionProviders => '拡張のプロバイダーを使用する';
@override
String get optionsUseExtensionProvidersOn => 'Extensions will be tried first';
@override
String get optionsUseExtensionProvidersOff => 'Using built-in providers only';
String get optionsUseExtensionProvidersOff => '内蔵のプロバイダーのみを使用する';
@override
String get optionsEmbedLyrics => 'Embed Lyrics';
String get optionsEmbedLyrics => '歌詞を埋め込む';
@override
String get optionsEmbedLyricsSubtitle =>
'Embed synced lyrics into FLAC files';
@override
String get optionsMaxQualityCover => 'Max Quality Cover';
String get optionsMaxQualityCover => '最大品質のカバー';
@override
String get optionsMaxQualityCoverSubtitle =>
@@ -281,26 +284,26 @@ class AppLocalizationsJa extends AppLocalizations {
'Parallel downloads may trigger rate limiting';
@override
String get optionsExtensionStore => 'Extension Store';
String get optionsExtensionStore => '拡張ストア';
@override
String get optionsExtensionStoreSubtitle => 'Show Store tab in navigation';
@override
String get optionsCheckUpdates => 'Check for Updates';
String get optionsCheckUpdates => '更新を確認';
@override
String get optionsCheckUpdatesSubtitle =>
'Notify when new version is available';
@override
String get optionsUpdateChannel => 'Update Channel';
String get optionsUpdateChannel => '更新チャンネル';
@override
String get optionsUpdateChannelStable => 'Stable releases only';
String get optionsUpdateChannelStable => '安定版リリースのみ';
@override
String get optionsUpdateChannelPreview => 'Get preview releases';
String get optionsUpdateChannelPreview => 'プレビューリリースを入手';
@override
String get optionsUpdateChannelWarning =>
@@ -323,11 +326,11 @@ class AppLocalizationsJa extends AppLocalizations {
String get optionsDetailedLoggingOff => 'Enable for bug reports';
@override
String get optionsSpotifyCredentials => 'Spotify Credentials';
String get optionsSpotifyCredentials => 'Spotify の認証情報';
@override
String optionsSpotifyCredentialsConfigured(String clientId) {
return 'Client ID: $clientId...';
return 'クライアント ID: $clientId...';
}
@override
@@ -338,62 +341,62 @@ class AppLocalizationsJa extends AppLocalizations {
'Spotify requires your own API credentials. Get them free from developer.spotify.com';
@override
String get extensionsTitle => 'Extensions';
String get extensionsTitle => '拡張';
@override
String get extensionsInstalled => 'Installed Extensions';
String get extensionsInstalled => 'インストール済みの拡張';
@override
String get extensionsNone => 'No extensions installed';
String get extensionsNone => '拡張はインストールされていません';
@override
String get extensionsNoneSubtitle => 'Install extensions from the Store tab';
String get extensionsNoneSubtitle => 'ストアタブから拡張をインストール';
@override
String get extensionsEnabled => 'Enabled';
String get extensionsEnabled => '有効';
@override
String get extensionsDisabled => 'Disabled';
@override
String extensionsVersion(String version) {
return 'Version $version';
return 'バージョン $version';
}
@override
String extensionsAuthor(String author) {
return 'by $author';
return '作者 $author';
}
@override
String get extensionsUninstall => 'Uninstall';
String get extensionsUninstall => 'アンインストール';
@override
String get extensionsSetAsSearch => 'Set as Search Provider';
String get extensionsSetAsSearch => '検索プロバイダーを設定';
@override
String get storeTitle => 'Extension Store';
String get storeTitle => '拡張ストア';
@override
String get storeSearch => 'Search extensions...';
String get storeSearch => '拡張を検索...';
@override
String get storeInstall => 'Install';
String get storeInstall => 'インストール';
@override
String get storeInstalled => 'Installed';
String get storeInstalled => 'インストール済み';
@override
String get storeUpdate => 'Update';
String get storeUpdate => '更新';
@override
String get aboutTitle => 'About';
String get aboutTitle => 'アプリについて';
@override
String get aboutContributors => 'Contributors';
String get aboutContributors => '貢献者';
@override
String get aboutMobileDeveloper => 'Mobile version developer';
String get aboutMobileDeveloper => 'モバイルバージョンの開発者';
@override
String get aboutOriginalCreator => 'Creator of the original SpotiFLAC';
@@ -403,43 +406,61 @@ class AppLocalizationsJa extends AppLocalizations {
'The talented artist who created our beautiful app logo!';
@override
String get aboutSpecialThanks => 'Special Thanks';
String get aboutTranslators => 'Translators';
@override
String get aboutLinks => 'Links';
String get aboutSpecialThanks => 'スペシャルサンクス';
@override
String get aboutMobileSource => 'Mobile source code';
String get aboutLinks => 'リンク';
@override
String get aboutPCSource => 'PC source code';
String get aboutMobileSource => 'モバイル版のソースコード';
@override
String get aboutReportIssue => 'Report an issue';
String get aboutPCSource => 'PC 版のソースコード';
@override
String get aboutReportIssue => 'Issue で報告する';
@override
String get aboutReportIssueSubtitle => 'Report any problems you encounter';
@override
String get aboutFeatureRequest => 'Feature request';
String get aboutFeatureRequest => '機能の要望';
@override
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
@override
String get aboutTelegramChannel => 'Telegram Channel';
@override
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
@override
String get aboutTelegramChat => 'Telegram Community';
@override
String get aboutTelegramChatSubtitle => 'Chat with other users';
@override
String get aboutSocial => 'Social';
@override
String get aboutSupport => 'Support';
@override
String get aboutBuyMeCoffee => 'Buy me a coffee';
String get aboutBuyMeCoffee => 'コーヒーを買ってください';
@override
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
String get aboutBuyMeCoffeeSubtitle => 'Ko-fi で開発をサポートします';
@override
String get aboutApp => 'App';
String get aboutApp => 'アプリ';
@override
String get aboutVersion => 'Version';
String get aboutVersion => 'バージョン';
@override
String get aboutBinimumDesc =>
@@ -497,10 +518,10 @@ class AppLocalizationsJa extends AppLocalizations {
String get artistAlbums => 'Albums';
@override
String get artistSingles => 'Singles & EPs';
String get artistSingles => 'シングルと EP';
@override
String get artistCompilations => 'Compilations';
String get artistCompilations => 'コンピレーション';
@override
String artistReleases(int count) {
@@ -589,13 +610,13 @@ class AppLocalizationsJa extends AppLocalizations {
String get setupChooseFolder => 'Choose Folder';
@override
String get setupContinue => 'Continue';
String get setupContinue => '続行';
@override
String get setupSkip => 'Skip for now';
String get setupSkip => '今はスキップ';
@override
String get setupStorageAccessRequired => 'Storage Access Required';
String get setupStorageAccessRequired => 'ストレージアクセスが必要です';
@override
String get setupStorageAccessMessage =>
@@ -675,7 +696,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get setupStepSpotify => 'Spotify';
@override
String get setupStepPermission => 'Permission';
String get setupStepPermission => '権限';
@override
String get setupStorageGranted => 'Storage Permission Granted!';
@@ -691,14 +712,14 @@ class AppLocalizationsJa extends AppLocalizations {
String get setupNotificationGranted => 'Notification Permission Granted!';
@override
String get setupNotificationEnable => 'Enable Notifications';
String get setupNotificationEnable => '通知を有効化する';
@override
String get setupNotificationDescription =>
'Get notified when downloads complete or require attention.';
@override
String get setupFolderSelected => 'Download Folder Selected!';
String get setupFolderSelected => 'ダウンロードフォルダが選択済みです!';
@override
String get setupFolderChoose => 'Choose Download Folder';
@@ -714,26 +735,26 @@ class AppLocalizationsJa extends AppLocalizations {
String get setupSelectFolder => 'Select Folder';
@override
String get setupSpotifyApiOptional => 'Spotify API (Optional)';
String get setupSpotifyApiOptional => 'Spotify API (任意)';
@override
String get setupSpotifyApiDescription =>
'Add your Spotify API credentials for better search results and access to Spotify-exclusive content.';
@override
String get setupUseSpotifyApi => 'Use Spotify API';
String get setupUseSpotifyApi => 'Spotify API を使用する';
@override
String get setupEnterCredentialsBelow => 'Enter your credentials below';
@override
String get setupUsingDeezer => 'Using Deezer (no account needed)';
String get setupUsingDeezer => 'Deezer を使用中 (アカウントは不要です)';
@override
String get setupEnterClientId => 'Enter Spotify Client ID';
String get setupEnterClientId => 'Spotify クライアント ID を入力';
@override
String get setupEnterClientSecret => 'Enter Spotify Client Secret';
String get setupEnterClientSecret => 'Spotify クライアントシークレットを入力';
@override
String get setupGetFreeCredentials =>
@@ -754,19 +775,19 @@ class AppLocalizationsJa extends AppLocalizations {
'Get notified about download progress and completion. This helps you track downloads when the app is in background.';
@override
String get setupSkipForNow => 'Skip for now';
String get setupSkipForNow => '今はスキップ';
@override
String get setupBack => 'Back';
String get setupBack => '戻る';
@override
String get setupNext => 'Next';
String get setupNext => '次へ';
@override
String get setupGetStarted => 'Get Started';
@override
String get setupSkipAndStart => 'Skip & Start';
String get setupSkipAndStart => 'スキップと開始';
@override
String get setupAllowAccessToManageFiles =>
@@ -858,7 +879,7 @@ class AppLocalizationsJa extends AppLocalizations {
'Are you sure you want to remove this extension? This cannot be undone.';
@override
String get dialogUninstallExtension => 'Uninstall Extension?';
String get dialogUninstallExtension => '拡張をアンインストールしますか?';
@override
String dialogUninstallExtensionMessage(String extensionName) {
@@ -887,13 +908,18 @@ class AppLocalizationsJa extends AppLocalizations {
}
@override
String get dialogImportPlaylistTitle => 'Import Playlist';
String get dialogImportPlaylistTitle => 'プレイリストをインポート';
@override
String dialogImportPlaylistMessage(int count) {
return 'Found $count tracks in CSV. Add them to download queue?';
}
@override
String csvImportTracks(int count) {
return '$count tracks from CSV';
}
@override
String snackbarAddedToQueue(String trackName) {
return 'Added \"$trackName\" to queue';
@@ -980,7 +1006,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get snackbarFailedToUpdate => 'Failed to update extension';
@override
String get errorRateLimited => 'Rate Limited';
String get errorRateLimited => 'レート制限';
@override
String get errorRateLimitedMessage =>
@@ -1178,7 +1204,7 @@ class AppLocalizationsJa extends AppLocalizations {
}
@override
String get updateDownload => 'Download';
String get updateDownload => 'ダウンロード';
@override
String get updateLater => 'Later';
@@ -1199,7 +1225,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get updateNewVersionReady => 'A new version is ready';
@override
String get updateCurrent => 'Current';
String get updateCurrent => '現在';
@override
String get updateNew => 'New';
@@ -1303,13 +1329,13 @@ class AppLocalizationsJa extends AppLocalizations {
String get logClearLogsMessage => 'Are you sure you want to clear all logs?';
@override
String get logIspBlocking => 'ISP BLOCKING DETECTED';
String get logIspBlocking => 'ISP のブロックを検出しました';
@override
String get logRateLimited => 'RATE LIMITED';
String get logRateLimited => 'レート制限';
@override
String get logNetworkError => 'NETWORK ERROR';
String get logNetworkError => 'ネットワークエラー';
@override
String get logTrackNotFound => 'TRACK NOT FOUND';
@@ -1427,6 +1453,35 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get sectionFileSettings => 'File Settings';
@override
String get sectionLyrics => 'Lyrics';
@override
String get lyricsMode => 'Lyrics Mode';
@override
String get lyricsModeDescription =>
'Choose how lyrics are saved with your downloads';
@override
String get lyricsModeEmbed => 'Embed in file';
@override
String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata';
@override
String get lyricsModeExternal => 'External .lrc file';
@override
String get lyricsModeExternalSubtitle =>
'Separate .lrc file for players like Samsung Music';
@override
String get lyricsModeBoth => 'Both';
@override
String get lyricsModeBothSubtitle => 'Embed and save .lrc file';
@override
String get sectionColor => 'Color';
@@ -1498,22 +1553,22 @@ class AppLocalizationsJa extends AppLocalizations {
String get trackMetadata => 'Metadata';
@override
String get trackFileInfo => 'File Info';
String get trackFileInfo => 'ファイル情報';
@override
String get trackLyrics => 'Lyrics';
String get trackLyrics => '歌詞';
@override
String get trackFileNotFound => 'File not found';
String get trackFileNotFound => 'ファイルがありません';
@override
String get trackOpenInDeezer => 'Open in Deezer';
String get trackOpenInDeezer => 'Deezer で開く';
@override
String get trackOpenInSpotify => 'Open in Spotify';
String get trackOpenInSpotify => 'Spotify で開く';
@override
String get trackTrackName => 'Track name';
String get trackTrackName => 'トラック名';
@override
String get trackArtist => 'Artist';
@@ -1539,6 +1594,15 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get trackReleaseDate => 'Release date';
@override
String get trackGenre => 'Genre';
@override
String get trackLabel => 'Label';
@override
String get trackCopyright => 'Copyright';
@override
String get trackDownloaded => 'Downloaded';
@@ -1636,16 +1700,16 @@ class AppLocalizationsJa extends AppLocalizations {
String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
@override
String get extensionDefaultProviderSubtitle => 'Use built-in search';
String get extensionDefaultProviderSubtitle => '内蔵の検索を使用する';
@override
String get extensionAuthor => 'Author';
String get extensionAuthor => '作者';
@override
String get extensionId => 'ID';
@override
String get extensionError => 'Error';
String get extensionError => 'エラー';
@override
String get extensionCapabilities => 'Capabilities';
@@ -1675,16 +1739,16 @@ class AppLocalizationsJa extends AppLocalizations {
String get extensionSettings => 'Settings';
@override
String get extensionRemoveButton => 'Remove Extension';
String get extensionRemoveButton => '拡張を削除';
@override
String get extensionUpdated => 'Updated';
String get extensionUpdated => '更新済み';
@override
String get extensionMinAppVersion => 'Min App Version';
String get extensionMinAppVersion => '最小のアプリバージョン';
@override
String get extensionCustomTrackMatching => 'Custom Track Matching';
String get extensionCustomTrackMatching => 'カスタムトラックマッチング';
@override
String get extensionPostProcessing => 'Post-Processing';
@@ -1708,17 +1772,17 @@ class AppLocalizationsJa extends AppLocalizations {
String get extensionsProviderPrioritySection => 'Provider Priority';
@override
String get extensionsInstalledSection => 'Installed Extensions';
String get extensionsInstalledSection => 'インストール済みの拡張';
@override
String get extensionsNoExtensions => 'No extensions installed';
String get extensionsNoExtensions => '拡張はインストールされていません';
@override
String get extensionsNoExtensionsSubtitle =>
'Install .spotiflac-ext files to add new providers';
@override
String get extensionsInstallButton => 'Install Extension';
String get extensionsInstallButton => '拡張をインストール';
@override
String get extensionsInfoTip =>
@@ -1765,22 +1829,38 @@ class AppLocalizationsJa extends AppLocalizations {
String get extensionsErrorLoading => 'Error loading extension';
@override
String get qualityFlacLossless => 'FLAC Lossless';
String get qualityFlacLossless => 'FLAC ロスレス';
@override
String get qualityFlacLosslessSubtitle => '16-bit / 44.1kHz';
@override
String get qualityHiResFlac => 'Hi-Res FLAC';
String get qualityHiResFlac => 'ハイレゾ FLAC';
@override
String get qualityHiResFlacSubtitle => '24-bit / up to 96kHz';
String get qualityHiResFlacSubtitle => '24-bit / 最大 96kHz';
@override
String get qualityHiResFlacMax => 'Hi-Res FLAC Max';
String get qualityHiResFlacMax => 'ハイレゾ FLAC 最大';
@override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
String get qualityHiResFlacMaxSubtitle => '24-bit / 最大 192kHz';
@override
String get qualityMp3 => 'MP3';
@override
String get qualityMp3Subtitle => '320kbps (converted from FLAC)';
@override
String get enableMp3Option => 'Enable MP3 Option';
@override
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available';
@override
String get enableMp3OptionSubtitleOff =>
'Downloads FLAC then converts to 320kbps MP3';
@override
String get qualityNote =>
@@ -1790,10 +1870,10 @@ class AppLocalizationsJa extends AppLocalizations {
String get downloadAskBeforeDownload => 'Ask Before Download';
@override
String get downloadDirectory => 'Download Directory';
String get downloadDirectory => 'ダウンロードディレクトリ';
@override
String get downloadSeparateSinglesFolder => 'Separate Singles Folder';
String get downloadSeparateSinglesFolder => 'シングルのフォルダを分割';
@override
String get downloadAlbumFolderStructure => 'Album Folder Structure';
@@ -1856,22 +1936,22 @@ class AppLocalizationsJa extends AppLocalizations {
String get serviceSpotify => 'Spotify';
@override
String get appearanceAmoledDark => 'AMOLED Dark';
String get appearanceAmoledDark => 'AMOLED ダーク';
@override
String get appearanceAmoledDarkSubtitle => 'Pure black background';
String get appearanceAmoledDarkSubtitle => 'ピュアブラックの背景';
@override
String get appearanceChooseAccentColor => 'Choose Accent Color';
@override
String get appearanceChooseTheme => 'Theme Mode';
String get appearanceChooseTheme => 'テーマモード';
@override
String get queueTitle => 'Download Queue';
String get queueTitle => 'ダウンロードキュー';
@override
String get queueClearAll => 'Clear All';
String get queueClearAll => 'すべて消去';
@override
String get queueClearAllMessage =>
@@ -1973,6 +2053,11 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
@override
String downloadedAlbumDiscHeader(int discNumber) {
return 'Disc $discNumber';
}
@override
String get utilityFunctions => 'Utility Functions';
@@ -1997,4 +2082,70 @@ class AppLocalizationsJa extends AppLocalizations {
String errorGeneric(String message) {
return 'Error: $message';
}
@override
String get discographyDownload => 'Download Discography';
@override
String get discographyDownloadAll => 'Download All';
@override
String discographyDownloadAllSubtitle(int count, int albumCount) {
return '$count tracks from $albumCount releases';
}
@override
String get discographyAlbumsOnly => 'Albums Only';
@override
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
return '$count tracks from $albumCount albums';
}
@override
String get discographySinglesOnly => 'Singles & EPs Only';
@override
String discographySinglesOnlySubtitle(int count, int albumCount) {
return '$count tracks from $albumCount singles';
}
@override
String get discographySelectAlbums => 'Select Albums...';
@override
String get discographySelectAlbumsSubtitle =>
'Choose specific albums or singles';
@override
String get discographyFetchingTracks => 'Fetching tracks...';
@override
String discographyFetchingAlbum(int current, int total) {
return 'Fetching $current of $total...';
}
@override
String discographySelectedCount(int count) {
return '$count selected';
}
@override
String get discographyDownloadSelected => 'Download Selected';
@override
String discographyAddedToQueue(int count) {
return 'Added $count tracks to queue';
}
@override
String discographySkippedDownloaded(int added, int skipped) {
return '$added added, $skipped already downloaded';
}
@override
String get discographyNoAlbums => 'No albums available';
@override
String get discographyFailedToFetch => 'Failed to fetch some albums';
}
+151
View File
@@ -109,6 +109,9 @@ class AppLocalizationsKo extends AppLocalizations {
String get historyNoSinglesSubtitle =>
'Single track downloads will appear here';
@override
String get historySearchHint => 'Search history...';
@override
String get settingsTitle => 'Settings';
@@ -402,6 +405,9 @@ class AppLocalizationsKo extends AppLocalizations {
String get aboutLogoArtist =>
'The talented artist who created our beautiful app logo!';
@override
String get aboutTranslators => 'Translators';
@override
String get aboutSpecialThanks => 'Special Thanks';
@@ -426,6 +432,21 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
@override
String get aboutTelegramChannel => 'Telegram Channel';
@override
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
@override
String get aboutTelegramChat => 'Telegram Community';
@override
String get aboutTelegramChatSubtitle => 'Chat with other users';
@override
String get aboutSocial => 'Social';
@override
String get aboutSupport => 'Support';
@@ -894,6 +915,11 @@ class AppLocalizationsKo extends AppLocalizations {
return 'Found $count tracks in CSV. Add them to download queue?';
}
@override
String csvImportTracks(int count) {
return '$count tracks from CSV';
}
@override
String snackbarAddedToQueue(String trackName) {
return 'Added \"$trackName\" to queue';
@@ -1427,6 +1453,35 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get sectionFileSettings => 'File Settings';
@override
String get sectionLyrics => 'Lyrics';
@override
String get lyricsMode => 'Lyrics Mode';
@override
String get lyricsModeDescription =>
'Choose how lyrics are saved with your downloads';
@override
String get lyricsModeEmbed => 'Embed in file';
@override
String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata';
@override
String get lyricsModeExternal => 'External .lrc file';
@override
String get lyricsModeExternalSubtitle =>
'Separate .lrc file for players like Samsung Music';
@override
String get lyricsModeBoth => 'Both';
@override
String get lyricsModeBothSubtitle => 'Embed and save .lrc file';
@override
String get sectionColor => 'Color';
@@ -1539,6 +1594,15 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get trackReleaseDate => 'Release date';
@override
String get trackGenre => 'Genre';
@override
String get trackLabel => 'Label';
@override
String get trackCopyright => 'Copyright';
@override
String get trackDownloaded => 'Downloaded';
@@ -1782,6 +1846,22 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override
String get qualityMp3 => 'MP3';
@override
String get qualityMp3Subtitle => '320kbps (converted from FLAC)';
@override
String get enableMp3Option => 'Enable MP3 Option';
@override
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available';
@override
String get enableMp3OptionSubtitleOff =>
'Downloads FLAC then converts to 320kbps MP3';
@override
String get qualityNote =>
'Actual quality depends on track availability from the service';
@@ -1973,6 +2053,11 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
@override
String downloadedAlbumDiscHeader(int discNumber) {
return 'Disc $discNumber';
}
@override
String get utilityFunctions => 'Utility Functions';
@@ -1997,4 +2082,70 @@ class AppLocalizationsKo extends AppLocalizations {
String errorGeneric(String message) {
return 'Error: $message';
}
@override
String get discographyDownload => 'Download Discography';
@override
String get discographyDownloadAll => 'Download All';
@override
String discographyDownloadAllSubtitle(int count, int albumCount) {
return '$count tracks from $albumCount releases';
}
@override
String get discographyAlbumsOnly => 'Albums Only';
@override
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
return '$count tracks from $albumCount albums';
}
@override
String get discographySinglesOnly => 'Singles & EPs Only';
@override
String discographySinglesOnlySubtitle(int count, int albumCount) {
return '$count tracks from $albumCount singles';
}
@override
String get discographySelectAlbums => 'Select Albums...';
@override
String get discographySelectAlbumsSubtitle =>
'Choose specific albums or singles';
@override
String get discographyFetchingTracks => 'Fetching tracks...';
@override
String discographyFetchingAlbum(int current, int total) {
return 'Fetching $current of $total...';
}
@override
String discographySelectedCount(int count) {
return '$count selected';
}
@override
String get discographyDownloadSelected => 'Download Selected';
@override
String discographyAddedToQueue(int count) {
return 'Added $count tracks to queue';
}
@override
String discographySkippedDownloaded(int added, int skipped) {
return '$added added, $skipped already downloaded';
}
@override
String get discographyNoAlbums => 'No albums available';
@override
String get discographyFailedToFetch => 'Failed to fetch some albums';
}
+151
View File
@@ -109,6 +109,9 @@ class AppLocalizationsNl extends AppLocalizations {
String get historyNoSinglesSubtitle =>
'Single track downloads will appear here';
@override
String get historySearchHint => 'Search history...';
@override
String get settingsTitle => 'Settings';
@@ -402,6 +405,9 @@ class AppLocalizationsNl extends AppLocalizations {
String get aboutLogoArtist =>
'The talented artist who created our beautiful app logo!';
@override
String get aboutTranslators => 'Translators';
@override
String get aboutSpecialThanks => 'Special Thanks';
@@ -426,6 +432,21 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
@override
String get aboutTelegramChannel => 'Telegram Channel';
@override
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
@override
String get aboutTelegramChat => 'Telegram Community';
@override
String get aboutTelegramChatSubtitle => 'Chat with other users';
@override
String get aboutSocial => 'Social';
@override
String get aboutSupport => 'Support';
@@ -894,6 +915,11 @@ class AppLocalizationsNl extends AppLocalizations {
return 'Found $count tracks in CSV. Add them to download queue?';
}
@override
String csvImportTracks(int count) {
return '$count tracks from CSV';
}
@override
String snackbarAddedToQueue(String trackName) {
return 'Added \"$trackName\" to queue';
@@ -1427,6 +1453,35 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get sectionFileSettings => 'File Settings';
@override
String get sectionLyrics => 'Lyrics';
@override
String get lyricsMode => 'Lyrics Mode';
@override
String get lyricsModeDescription =>
'Choose how lyrics are saved with your downloads';
@override
String get lyricsModeEmbed => 'Embed in file';
@override
String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata';
@override
String get lyricsModeExternal => 'External .lrc file';
@override
String get lyricsModeExternalSubtitle =>
'Separate .lrc file for players like Samsung Music';
@override
String get lyricsModeBoth => 'Both';
@override
String get lyricsModeBothSubtitle => 'Embed and save .lrc file';
@override
String get sectionColor => 'Color';
@@ -1539,6 +1594,15 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get trackReleaseDate => 'Release date';
@override
String get trackGenre => 'Genre';
@override
String get trackLabel => 'Label';
@override
String get trackCopyright => 'Copyright';
@override
String get trackDownloaded => 'Downloaded';
@@ -1782,6 +1846,22 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override
String get qualityMp3 => 'MP3';
@override
String get qualityMp3Subtitle => '320kbps (converted from FLAC)';
@override
String get enableMp3Option => 'Enable MP3 Option';
@override
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available';
@override
String get enableMp3OptionSubtitleOff =>
'Downloads FLAC then converts to 320kbps MP3';
@override
String get qualityNote =>
'Actual quality depends on track availability from the service';
@@ -1973,6 +2053,11 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
@override
String downloadedAlbumDiscHeader(int discNumber) {
return 'Disc $discNumber';
}
@override
String get utilityFunctions => 'Utility Functions';
@@ -1997,4 +2082,70 @@ class AppLocalizationsNl extends AppLocalizations {
String errorGeneric(String message) {
return 'Error: $message';
}
@override
String get discographyDownload => 'Download Discography';
@override
String get discographyDownloadAll => 'Download All';
@override
String discographyDownloadAllSubtitle(int count, int albumCount) {
return '$count tracks from $albumCount releases';
}
@override
String get discographyAlbumsOnly => 'Albums Only';
@override
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
return '$count tracks from $albumCount albums';
}
@override
String get discographySinglesOnly => 'Singles & EPs Only';
@override
String discographySinglesOnlySubtitle(int count, int albumCount) {
return '$count tracks from $albumCount singles';
}
@override
String get discographySelectAlbums => 'Select Albums...';
@override
String get discographySelectAlbumsSubtitle =>
'Choose specific albums or singles';
@override
String get discographyFetchingTracks => 'Fetching tracks...';
@override
String discographyFetchingAlbum(int current, int total) {
return 'Fetching $current of $total...';
}
@override
String discographySelectedCount(int count) {
return '$count selected';
}
@override
String get discographyDownloadSelected => 'Download Selected';
@override
String discographyAddedToQueue(int count) {
return 'Added $count tracks to queue';
}
@override
String discographySkippedDownloaded(int added, int skipped) {
return '$added added, $skipped already downloaded';
}
@override
String get discographyNoAlbums => 'No albums available';
@override
String get discographyFailedToFetch => 'Failed to fetch some albums';
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+174 -136
View File
@@ -5,19 +5,19 @@
"@appName": {
"description": "App name - DO NOT TRANSLATE"
},
"appDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.",
"appDescription": "Laden Sie Spotify-Titel in verlustfreier Qualität von Tidal, Qobuz und Amazon Music herunter.",
"@appDescription": {
"description": "App description shown in about page"
},
"navHome": "Home",
"navHome": "Startseite",
"@navHome": {
"description": "Bottom navigation - Home tab"
},
"navHistory": "History",
"navHistory": "Verlauf",
"@navHistory": {
"description": "Bottom navigation - History tab"
},
"navSettings": "Settings",
"navSettings": "Einstellungen",
"@navSettings": {
"description": "Bottom navigation - Settings tab"
},
@@ -25,15 +25,15 @@
"@navStore": {
"description": "Bottom navigation - Extension store tab"
},
"homeTitle": "Home",
"homeTitle": "Startseite",
"@homeTitle": {
"description": "Home screen title"
},
"homeSearchHint": "Paste Spotify URL or search...",
"homeSearchHint": "Spotify-URL einfügen oder suchen...",
"@homeSearchHint": {
"description": "Placeholder text in search box"
},
"homeSearchHintExtension": "Search with {extensionName}...",
"homeSearchHintExtension": "Mit {extensionName} suchen...",
"@homeSearchHintExtension": {
"description": "Placeholder when extension search is active",
"placeholders": {
@@ -43,23 +43,23 @@
}
}
},
"homeSubtitle": "Paste a Spotify link or search by name",
"homeSubtitle": "Spotify-Link einfügen oder nach Namen suchen",
"@homeSubtitle": {
"description": "Subtitle shown below search box"
},
"homeSupports": "Supports: Track, Album, Playlist, Artist URLs",
"homeSupports": "Unterstützt: Titel, Album, Playlist, Künstler-URLs",
"@homeSupports": {
"description": "Info text about supported URL types"
},
"homeRecent": "Recent",
"homeRecent": "Zuletzt",
"@homeRecent": {
"description": "Section header for recent searches"
},
"historyTitle": "History",
"historyTitle": "Verlauf",
"@historyTitle": {
"description": "History screen title"
},
"historyDownloading": "Downloading ({count})",
"historyDownloading": "Wird heruntergeladen ({count})",
"@historyDownloading": {
"description": "Tab showing active downloads count",
"placeholders": {
@@ -69,15 +69,15 @@
}
}
},
"historyDownloaded": "Downloaded",
"historyDownloaded": "Heruntergeladen",
"@historyDownloaded": {
"description": "Tab showing completed downloads"
},
"historyFilterAll": "All",
"historyFilterAll": "Alle",
"@historyFilterAll": {
"description": "Filter chip - show all items"
},
"historyFilterAlbums": "Albums",
"historyFilterAlbums": "Alben",
"@historyFilterAlbums": {
"description": "Filter chip - show albums only"
},
@@ -85,7 +85,7 @@
"@historyFilterSingles": {
"description": "Filter chip - show singles only"
},
"historyTracksCount": "{count, plural, =1{1 track} other{{count} tracks}}",
"historyTracksCount": "{count, plural, =1{1 Titel} other{{count} Titel}}",
"@historyTracksCount": {
"description": "Track count with plural form",
"placeholders": {
@@ -94,7 +94,7 @@
}
}
},
"historyAlbumsCount": "{count, plural, =1{1 album} other{{count} albums}}",
"historyAlbumsCount": "{count, plural, =1{1 Album} other{{count} Alben}}",
"@historyAlbumsCount": {
"description": "Album count with plural form",
"placeholders": {
@@ -103,107 +103,107 @@
}
}
},
"historyNoDownloads": "No download history",
"historyNoDownloads": "Kein Download-Verlauf",
"@historyNoDownloads": {
"description": "Empty state title"
},
"historyNoDownloadsSubtitle": "Downloaded tracks will appear here",
"historyNoDownloadsSubtitle": "Heruntergeladene Titel werden hier angezeigt",
"@historyNoDownloadsSubtitle": {
"description": "Empty state subtitle"
},
"historyNoAlbums": "No album downloads",
"historyNoAlbums": "Keine Album-Downloads",
"@historyNoAlbums": {
"description": "Empty state when filtering albums"
},
"historyNoAlbumsSubtitle": "Download multiple tracks from an album to see them here",
"historyNoAlbumsSubtitle": "Laden Sie mehrere Titel eines Albums herunter, um sie hier zu sehen",
"@historyNoAlbumsSubtitle": {
"description": "Empty state subtitle for albums filter"
},
"historyNoSingles": "No single downloads",
"historyNoSingles": "Keine Einzel-Downloads",
"@historyNoSingles": {
"description": "Empty state when filtering singles"
},
"historyNoSinglesSubtitle": "Single track downloads will appear here",
"historyNoSinglesSubtitle": "Einzelne Titel-Downloads werden hier angezeigt",
"@historyNoSinglesSubtitle": {
"description": "Empty state subtitle for singles filter"
},
"settingsTitle": "Settings",
"settingsTitle": "Einstellungen",
"@settingsTitle": {
"description": "Settings screen title"
},
"settingsDownload": "Download",
"settingsDownload": "Herunterladen",
"@settingsDownload": {
"description": "Settings section - download options"
},
"settingsAppearance": "Appearance",
"settingsAppearance": "Erscheinungsbild",
"@settingsAppearance": {
"description": "Settings section - visual customization"
},
"settingsOptions": "Options",
"settingsOptions": "Optionen",
"@settingsOptions": {
"description": "Settings section - app options"
},
"settingsExtensions": "Extensions",
"settingsExtensions": "Erweiterungen",
"@settingsExtensions": {
"description": "Settings section - extension management"
},
"settingsAbout": "About",
"settingsAbout": "Über",
"@settingsAbout": {
"description": "Settings section - app info"
},
"downloadTitle": "Download",
"downloadTitle": "Herunterladen",
"@downloadTitle": {
"description": "Download settings page title"
},
"downloadLocation": "Download Location",
"downloadLocation": "Download-Speicherort",
"@downloadLocation": {
"description": "Setting for download folder"
},
"downloadLocationSubtitle": "Choose where to save files",
"downloadLocationSubtitle": "Wählen Sie den Speicherort für Dateien",
"@downloadLocationSubtitle": {
"description": "Subtitle for download location"
},
"downloadLocationDefault": "Default location",
"downloadLocationDefault": "Standard-Speicherort",
"@downloadLocationDefault": {
"description": "Shown when using default folder"
},
"downloadDefaultService": "Default Service",
"downloadDefaultService": "Standard-Dienst",
"@downloadDefaultService": {
"description": "Setting for preferred download service (Tidal/Qobuz/Amazon)"
},
"downloadDefaultServiceSubtitle": "Service used for downloads",
"downloadDefaultServiceSubtitle": "Dienst für Downloads",
"@downloadDefaultServiceSubtitle": {
"description": "Subtitle for default service"
},
"downloadDefaultQuality": "Default Quality",
"downloadDefaultQuality": "Standard-Qualität",
"@downloadDefaultQuality": {
"description": "Setting for audio quality"
},
"downloadAskQuality": "Ask Quality Before Download",
"downloadAskQuality": "Qualität vor Download abfragen",
"@downloadAskQuality": {
"description": "Toggle to show quality picker"
},
"downloadAskQualitySubtitle": "Show quality picker for each download",
"downloadAskQualitySubtitle": "Qualitätsauswahl für jeden Download anzeigen",
"@downloadAskQualitySubtitle": {
"description": "Subtitle for ask quality toggle"
},
"downloadFilenameFormat": "Filename Format",
"downloadFilenameFormat": "Dateinamenformat",
"@downloadFilenameFormat": {
"description": "Setting for output filename pattern"
},
"downloadFolderOrganization": "Folder Organization",
"downloadFolderOrganization": "Ordnerstruktur",
"@downloadFolderOrganization": {
"description": "Setting for folder structure"
},
"downloadSeparateSingles": "Separate Singles",
"downloadSeparateSingles": "Singles trennen",
"@downloadSeparateSingles": {
"description": "Toggle to separate single tracks"
},
"downloadSeparateSinglesSubtitle": "Put single tracks in a separate folder",
"downloadSeparateSinglesSubtitle": "Einzelne Titel in separatem Ordner speichern",
"@downloadSeparateSinglesSubtitle": {
"description": "Subtitle for separate singles toggle"
},
"qualityBest": "Best Available",
"qualityBest": "Beste Qualität",
"@qualityBest": {
"description": "Audio quality option - highest available"
},
@@ -219,11 +219,11 @@
"@quality128": {
"description": "Audio quality option - 128kbps MP3"
},
"appearanceTitle": "Appearance",
"appearanceTitle": "Erscheinungsbild",
"@appearanceTitle": {
"description": "Appearance settings page title"
},
"appearanceTheme": "Theme",
"appearanceTheme": "Design",
"@appearanceTheme": {
"description": "Theme mode setting"
},
@@ -231,55 +231,55 @@
"@appearanceThemeSystem": {
"description": "Follow system theme"
},
"appearanceThemeLight": "Light",
"appearanceThemeLight": "Hell",
"@appearanceThemeLight": {
"description": "Light theme"
},
"appearanceThemeDark": "Dark",
"appearanceThemeDark": "Dunkel",
"@appearanceThemeDark": {
"description": "Dark theme"
},
"appearanceDynamicColor": "Dynamic Color",
"appearanceDynamicColor": "Dynamische Farben",
"@appearanceDynamicColor": {
"description": "Material You dynamic colors"
},
"appearanceDynamicColorSubtitle": "Use colors from your wallpaper",
"appearanceDynamicColorSubtitle": "Farben von Ihrem Hintergrundbild verwenden",
"@appearanceDynamicColorSubtitle": {
"description": "Subtitle for dynamic color"
},
"appearanceAccentColor": "Accent Color",
"appearanceAccentColor": "Akzentfarbe",
"@appearanceAccentColor": {
"description": "Custom accent color picker"
},
"appearanceHistoryView": "History View",
"appearanceHistoryView": "Verlaufsansicht",
"@appearanceHistoryView": {
"description": "Layout style for history"
},
"appearanceHistoryViewList": "List",
"appearanceHistoryViewList": "Liste",
"@appearanceHistoryViewList": {
"description": "List layout option"
},
"appearanceHistoryViewGrid": "Grid",
"appearanceHistoryViewGrid": "Raster",
"@appearanceHistoryViewGrid": {
"description": "Grid layout option"
},
"optionsTitle": "Options",
"optionsTitle": "Optionen",
"@optionsTitle": {
"description": "Options settings page title"
},
"optionsSearchSource": "Search Source",
"optionsSearchSource": "Suchquelle",
"@optionsSearchSource": {
"description": "Section for search provider settings"
},
"optionsPrimaryProvider": "Primary Provider",
"optionsPrimaryProvider": "Primärer Anbieter",
"@optionsPrimaryProvider": {
"description": "Main search provider setting"
},
"optionsPrimaryProviderSubtitle": "Service used when searching by track name.",
"optionsPrimaryProviderSubtitle": "Dienst für die Suche nach Titelnamen.",
"@optionsPrimaryProviderSubtitle": {
"description": "Subtitle for primary provider"
},
"optionsUsingExtension": "Using extension: {extensionName}",
"optionsUsingExtension": "Erweiterung verwenden: {extensionName}",
"@optionsUsingExtension": {
"description": "Shows active extension name",
"placeholders": {
@@ -288,55 +288,55 @@
}
}
},
"optionsSwitchBack": "Tap Deezer or Spotify to switch back from extension",
"optionsSwitchBack": "Tippen Sie auf Deezer oder Spotify, um von der Erweiterung zurückzuwechseln",
"@optionsSwitchBack": {
"description": "Hint to switch back to built-in providers"
},
"optionsAutoFallback": "Auto Fallback",
"optionsAutoFallback": "Automatischer Fallback",
"@optionsAutoFallback": {
"description": "Auto-retry with other services"
},
"optionsAutoFallbackSubtitle": "Try other services if download fails",
"optionsAutoFallbackSubtitle": "Andere Dienste versuchen, wenn Download fehlschlägt",
"@optionsAutoFallbackSubtitle": {
"description": "Subtitle for auto fallback"
},
"optionsUseExtensionProviders": "Use Extension Providers",
"optionsUseExtensionProviders": "Erweiterungs-Anbieter verwenden",
"@optionsUseExtensionProviders": {
"description": "Enable extension download providers"
},
"optionsUseExtensionProvidersOn": "Extensions will be tried first",
"optionsUseExtensionProvidersOn": "Erweiterungen werden zuerst versucht",
"@optionsUseExtensionProvidersOn": {
"description": "Status when extension providers enabled"
},
"optionsUseExtensionProvidersOff": "Using built-in providers only",
"optionsUseExtensionProvidersOff": "Nur integrierte Anbieter verwenden",
"@optionsUseExtensionProvidersOff": {
"description": "Status when extension providers disabled"
},
"optionsEmbedLyrics": "Embed Lyrics",
"optionsEmbedLyrics": "Liedtexte einbetten",
"@optionsEmbedLyrics": {
"description": "Embed lyrics in audio files"
},
"optionsEmbedLyricsSubtitle": "Embed synced lyrics into FLAC files",
"optionsEmbedLyricsSubtitle": "Synchronisierte Liedtexte in FLAC-Dateien einbetten",
"@optionsEmbedLyricsSubtitle": {
"description": "Subtitle for embed lyrics"
},
"optionsMaxQualityCover": "Max Quality Cover",
"optionsMaxQualityCover": "Maximale Cover-Qualität",
"@optionsMaxQualityCover": {
"description": "Download highest quality album art"
},
"optionsMaxQualityCoverSubtitle": "Download highest resolution cover art",
"optionsMaxQualityCoverSubtitle": "Cover in höchster Auflösung herunterladen",
"@optionsMaxQualityCoverSubtitle": {
"description": "Subtitle for max quality cover"
},
"optionsConcurrentDownloads": "Concurrent Downloads",
"optionsConcurrentDownloads": "Parallele Downloads",
"@optionsConcurrentDownloads": {
"description": "Number of parallel downloads"
},
"optionsConcurrentSequential": "Sequential (1 at a time)",
"optionsConcurrentSequential": "Sequentiell (1 gleichzeitig)",
"@optionsConcurrentSequential": {
"description": "Download one at a time"
},
"optionsConcurrentParallel": "{count} parallel downloads",
"optionsConcurrentParallel": "{count} parallele Downloads",
"@optionsConcurrentParallel": {
"description": "Multiple parallel downloads",
"placeholders": {
@@ -345,67 +345,67 @@
}
}
},
"optionsConcurrentWarning": "Parallel downloads may trigger rate limiting",
"optionsConcurrentWarning": "Parallele Downloads können Ratenlimitierung auslösen",
"@optionsConcurrentWarning": {
"description": "Warning about rate limits"
},
"optionsExtensionStore": "Extension Store",
"optionsExtensionStore": "Erweiterungs-Store",
"@optionsExtensionStore": {
"description": "Show/hide store tab"
},
"optionsExtensionStoreSubtitle": "Show Store tab in navigation",
"optionsExtensionStoreSubtitle": "Store-Tab in Navigation anzeigen",
"@optionsExtensionStoreSubtitle": {
"description": "Subtitle for extension store toggle"
},
"optionsCheckUpdates": "Check for Updates",
"optionsCheckUpdates": "Nach Updates suchen",
"@optionsCheckUpdates": {
"description": "Auto update check toggle"
},
"optionsCheckUpdatesSubtitle": "Notify when new version is available",
"optionsCheckUpdatesSubtitle": "Benachrichtigen, wenn neue Version verfügbar",
"@optionsCheckUpdatesSubtitle": {
"description": "Subtitle for update check"
},
"optionsUpdateChannel": "Update Channel",
"optionsUpdateChannel": "Update-Kanal",
"@optionsUpdateChannel": {
"description": "Stable vs preview releases"
},
"optionsUpdateChannelStable": "Stable releases only",
"optionsUpdateChannelStable": "Nur stabile Versionen",
"@optionsUpdateChannelStable": {
"description": "Only stable updates"
},
"optionsUpdateChannelPreview": "Get preview releases",
"optionsUpdateChannelPreview": "Vorschau-Versionen erhalten",
"@optionsUpdateChannelPreview": {
"description": "Include beta/preview updates"
},
"optionsUpdateChannelWarning": "Preview may contain bugs or incomplete features",
"optionsUpdateChannelWarning": "Vorschau kann Fehler oder unvollständige Funktionen enthalten",
"@optionsUpdateChannelWarning": {
"description": "Warning about preview channel"
},
"optionsClearHistory": "Clear Download History",
"optionsClearHistory": "Download-Verlauf löschen",
"@optionsClearHistory": {
"description": "Delete all download history"
},
"optionsClearHistorySubtitle": "Remove all downloaded tracks from history",
"optionsClearHistorySubtitle": "Alle heruntergeladenen Titel aus dem Verlauf entfernen",
"@optionsClearHistorySubtitle": {
"description": "Subtitle for clear history"
},
"optionsDetailedLogging": "Detailed Logging",
"optionsDetailedLogging": "Detaillierte Protokollierung",
"@optionsDetailedLogging": {
"description": "Enable verbose logs for debugging"
},
"optionsDetailedLoggingOn": "Detailed logs are being recorded",
"optionsDetailedLoggingOn": "Detaillierte Protokolle werden aufgezeichnet",
"@optionsDetailedLoggingOn": {
"description": "Status when logging enabled"
},
"optionsDetailedLoggingOff": "Enable for bug reports",
"optionsDetailedLoggingOff": "Für Fehlerberichte aktivieren",
"@optionsDetailedLoggingOff": {
"description": "Status when logging disabled"
},
"optionsSpotifyCredentials": "Spotify Credentials",
"optionsSpotifyCredentials": "Spotify-Anmeldedaten",
"@optionsSpotifyCredentials": {
"description": "Spotify API credentials setting"
},
"optionsSpotifyCredentialsConfigured": "Client ID: {clientId}...",
"optionsSpotifyCredentialsConfigured": "Client-ID: {clientId}...",
"@optionsSpotifyCredentialsConfigured": {
"description": "Shows configured client ID preview",
"placeholders": {
@@ -414,35 +414,35 @@
}
}
},
"optionsSpotifyCredentialsRequired": "Required - tap to configure",
"optionsSpotifyCredentialsRequired": "Erforderlich - zum Konfigurieren tippen",
"@optionsSpotifyCredentialsRequired": {
"description": "Prompt to set up credentials"
},
"optionsSpotifyWarning": "Spotify requires your own API credentials. Get them free from developer.spotify.com",
"optionsSpotifyWarning": "Spotify erfordert eigene API-Anmeldedaten. Kostenlos erhältlich auf developer.spotify.com",
"@optionsSpotifyWarning": {
"description": "Info about Spotify API requirement"
},
"extensionsTitle": "Extensions",
"extensionsTitle": "Erweiterungen",
"@extensionsTitle": {
"description": "Extensions page title"
},
"extensionsInstalled": "Installed Extensions",
"extensionsInstalled": "Installierte Erweiterungen",
"@extensionsInstalled": {
"description": "Section header for installed extensions"
},
"extensionsNone": "No extensions installed",
"extensionsNone": "Keine Erweiterungen installiert",
"@extensionsNone": {
"description": "Empty state title"
},
"extensionsNoneSubtitle": "Install extensions from the Store tab",
"extensionsNoneSubtitle": "Erweiterungen aus dem Store-Tab installieren",
"@extensionsNoneSubtitle": {
"description": "Empty state subtitle"
},
"extensionsEnabled": "Enabled",
"extensionsEnabled": "Aktiviert",
"@extensionsEnabled": {
"description": "Extension status - active"
},
"extensionsDisabled": "Disabled",
"extensionsDisabled": "Deaktiviert",
"@extensionsDisabled": {
"description": "Extension status - inactive"
},
@@ -455,7 +455,7 @@
}
}
},
"extensionsAuthor": "by {author}",
"extensionsAuthor": "von {author}",
"@extensionsAuthor": {
"description": "Extension author credit",
"placeholders": {
@@ -464,55 +464,55 @@
}
}
},
"extensionsUninstall": "Uninstall",
"extensionsUninstall": "Deinstallieren",
"@extensionsUninstall": {
"description": "Uninstall extension button"
},
"extensionsSetAsSearch": "Set as Search Provider",
"extensionsSetAsSearch": "Als Suchanbieter festlegen",
"@extensionsSetAsSearch": {
"description": "Use extension for search"
},
"storeTitle": "Extension Store",
"storeTitle": "Erweiterungs-Store",
"@storeTitle": {
"description": "Store screen title"
},
"storeSearch": "Search extensions...",
"storeSearch": "Erweiterungen suchen...",
"@storeSearch": {
"description": "Store search placeholder"
},
"storeInstall": "Install",
"storeInstall": "Installieren",
"@storeInstall": {
"description": "Install extension button"
},
"storeInstalled": "Installed",
"storeInstalled": "Installiert",
"@storeInstalled": {
"description": "Already installed badge"
},
"storeUpdate": "Update",
"storeUpdate": "Aktualisieren",
"@storeUpdate": {
"description": "Update available button"
},
"aboutTitle": "About",
"aboutTitle": "Über",
"@aboutTitle": {
"description": "About page title"
},
"aboutContributors": "Contributors",
"aboutContributors": "Mitwirkende",
"@aboutContributors": {
"description": "Section for contributors"
},
"aboutMobileDeveloper": "Mobile version developer",
"aboutMobileDeveloper": "Mobile-Version Entwickler",
"@aboutMobileDeveloper": {
"description": "Role description for mobile dev"
},
"aboutOriginalCreator": "Creator of the original SpotiFLAC",
"aboutOriginalCreator": "Schöpfer des ursprünglichen SpotiFLAC",
"@aboutOriginalCreator": {
"description": "Role description for original creator"
},
"aboutLogoArtist": "The talented artist who created our beautiful app logo!",
"aboutLogoArtist": "Der talentierte Künstler, der unser wunderschönes App-Logo entworfen hat!",
"@aboutLogoArtist": {
"description": "Role description for logo artist"
},
"aboutSpecialThanks": "Special Thanks",
"aboutSpecialThanks": "Besonderer Dank",
"@aboutSpecialThanks": {
"description": "Section for special thanks"
},
@@ -520,27 +520,27 @@
"@aboutLinks": {
"description": "Section for external links"
},
"aboutMobileSource": "Mobile source code",
"aboutMobileSource": "Mobiler Quellcode",
"@aboutMobileSource": {
"description": "Link to mobile GitHub repo"
},
"aboutPCSource": "PC source code",
"aboutPCSource": "PC Quellcode",
"@aboutPCSource": {
"description": "Link to PC GitHub repo"
},
"aboutReportIssue": "Report an issue",
"aboutReportIssue": "Problem melden",
"@aboutReportIssue": {
"description": "Link to report bugs"
},
"aboutReportIssueSubtitle": "Report any problems you encounter",
"aboutReportIssueSubtitle": "Melde jedes Problem, die dir auftreten",
"@aboutReportIssueSubtitle": {
"description": "Subtitle for report issue"
},
"aboutFeatureRequest": "Feature request",
"aboutFeatureRequest": "Feature vorschlagen",
"@aboutFeatureRequest": {
"description": "Link to suggest features"
},
"aboutFeatureRequestSubtitle": "Suggest new features for the app",
"aboutFeatureRequestSubtitle": "Schlage neue Funktionen für die App vor",
"@aboutFeatureRequestSubtitle": {
"description": "Subtitle for feature request"
},
@@ -548,11 +548,11 @@
"@aboutSupport": {
"description": "Section for support/donation links"
},
"aboutBuyMeCoffee": "Buy me a coffee",
"aboutBuyMeCoffee": "Spendiere mir einen Kaffee",
"@aboutBuyMeCoffee": {
"description": "Donation link"
},
"aboutBuyMeCoffeeSubtitle": "Support development on Ko-fi",
"aboutBuyMeCoffeeSubtitle": "Unterstütze die Entwicklung auf Ko-fi",
"@aboutBuyMeCoffeeSubtitle": {
"description": "Subtitle for donation"
},
@@ -564,11 +564,11 @@
"@aboutVersion": {
"description": "Version info label"
},
"aboutBinimumDesc": "The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn't exist!",
"aboutBinimumDesc": "Der Schöpfer der QQDL & HiFi API. Ohne diese API gäbe es keine Tidal-Downloads!",
"@aboutBinimumDesc": {
"description": "Credit description for binimum"
},
"aboutSachinsenalDesc": "The original HiFi project creator. The foundation of Tidal integration!",
"aboutSachinsenalDesc": "Der ursprüngliche Entwickler des HiFi-Projekts. Die Grundlage der Tidal-Integration!",
"@aboutSachinsenalDesc": {
"description": "Credit description for sachinsenal0x64"
},
@@ -576,7 +576,7 @@
"@aboutDoubleDouble": {
"description": "Name of Amazon API service - DO NOT TRANSLATE"
},
"aboutDoubleDoubleDesc": "Amazing API for Amazon Music downloads. Thank you for making it free!",
"aboutDoubleDoubleDesc": "Wundervolle API für Amazon Music Downloads.\nVielen Dank, dass Sie sie kostenlos zur Verfügung stellen!",
"@aboutDoubleDoubleDesc": {
"description": "Credit for DoubleDouble API"
},
@@ -584,7 +584,7 @@
"@aboutDabMusic": {
"description": "Name of Qobuz API service - DO NOT TRANSLATE"
},
"aboutDabMusicDesc": "The best Qobuz streaming API. Hi-Res downloads wouldn't be possible without this!",
"aboutDabMusicDesc": "Die beste Qobuz-Streaming-API. Hi-Res-Downloads wären ohne diese nicht möglich!",
"@aboutDabMusicDesc": {
"description": "Credit for DAB Music API"
},
@@ -642,6 +642,20 @@
}
}
},
"artistPopular": "Popular",
"@artistPopular": {
"description": "Section header for popular/top tracks"
},
"artistMonthlyListeners": "{count} monthly listeners",
"@artistMonthlyListeners": {
"description": "Monthly listener count display",
"placeholders": {
"count": {
"type": "String",
"description": "Formatted listener count"
}
}
},
"trackMetadataTitle": "Track Info",
"@trackMetadataTitle": {
"description": "Track metadata screen title"
@@ -1851,27 +1865,15 @@
},
"sectionLanguage": "Language",
"@sectionLanguage": {
"description": "Settings section header for language selection"
"description": "Settings section header for language"
},
"appearanceLanguage": "App Language",
"@appearanceLanguage": {
"description": "Setting title for language selection"
"description": "Language setting title"
},
"appearanceLanguageSubtitle": "Choose your preferred language",
"@appearanceLanguageSubtitle": {
"description": "Subtitle for language setting"
},
"languageSystem": "System Default",
"@languageSystem": {
"description": "Use device system language"
},
"languageEnglish": "English",
"@languageEnglish": {
"description": "English language option"
},
"languageIndonesian": "Bahasa Indonesia",
"@languageIndonesian": {
"description": "Indonesian language option"
"description": "Language setting subtitle"
},
"settingsAppearanceSubtitle": "Theme, colors, display",
"@settingsAppearanceSubtitle": {
@@ -2573,5 +2575,41 @@
"utilityFunctions": "Utility Functions",
"@utilityFunctions": {
"description": "Extension capability - utility functions"
},
"recentTypeArtist": "Artist",
"@recentTypeArtist": {
"description": "Recent access item type - artist"
},
"recentTypeAlbum": "Album",
"@recentTypeAlbum": {
"description": "Recent access item type - album"
},
"recentTypeSong": "Song",
"@recentTypeSong": {
"description": "Recent access item type - song/track"
},
"recentTypePlaylist": "Playlist",
"@recentTypePlaylist": {
"description": "Recent access item type - playlist"
},
"recentPlaylistInfo": "Playlist: {name}",
"@recentPlaylistInfo": {
"description": "Snackbar message when tapping playlist in recent access",
"placeholders": {
"name": {
"type": "String",
"description": "Playlist name"
}
}
},
"errorGeneric": "Error: {message}",
"@errorGeneric": {
"description": "Generic error message format",
"placeholders": {
"message": {
"type": "String",
"description": "Error message"
}
}
}
}
+142 -3
View File
@@ -75,8 +75,10 @@
"@historyNoAlbumsSubtitle": {"description": "Empty state subtitle for albums filter"},
"historyNoSingles": "No single downloads",
"@historyNoSingles": {"description": "Empty state when filtering singles"},
"historyNoSinglesSubtitle": "Single track downloads will appear here",
"historyNoSinglesSubtitle": "Single track downloads will appear here",
"@historyNoSinglesSubtitle": {"description": "Empty state subtitle for singles filter"},
"historySearchHint": "Search history...",
"@historySearchHint": {"description": "Search bar placeholder in history"},
"settingsTitle": "Settings",
"@settingsTitle": {"description": "Settings screen title"},
@@ -290,6 +292,8 @@
"@aboutOriginalCreator": {"description": "Role description for original creator"},
"aboutLogoArtist": "The talented artist who created our beautiful app logo!",
"@aboutLogoArtist": {"description": "Role description for logo artist"},
"aboutTranslators": "Translators",
"@aboutTranslators": {"description": "Section for translators"},
"aboutSpecialThanks": "Special Thanks",
"@aboutSpecialThanks": {"description": "Section for special thanks"},
"aboutLinks": "Links",
@@ -302,10 +306,20 @@
"@aboutReportIssue": {"description": "Link to report bugs"},
"aboutReportIssueSubtitle": "Report any problems you encounter",
"@aboutReportIssueSubtitle": {"description": "Subtitle for report issue"},
"aboutFeatureRequest": "Feature request",
"aboutFeatureRequest": "Feature request",
"@aboutFeatureRequest": {"description": "Link to suggest features"},
"aboutFeatureRequestSubtitle": "Suggest new features for the app",
"@aboutFeatureRequestSubtitle": {"description": "Subtitle for feature request"},
"aboutTelegramChannel": "Telegram Channel",
"@aboutTelegramChannel": {"description": "Link to Telegram channel"},
"aboutTelegramChannelSubtitle": "Announcements and updates",
"@aboutTelegramChannelSubtitle": {"description": "Subtitle for Telegram channel"},
"aboutTelegramChat": "Telegram Community",
"@aboutTelegramChat": {"description": "Link to Telegram chat group"},
"aboutTelegramChatSubtitle": "Chat with other users",
"@aboutTelegramChatSubtitle": {"description": "Subtitle for Telegram chat"},
"aboutSocial": "Social",
"@aboutSocial": {"description": "Section for social links"},
"aboutSupport": "Support",
"@aboutSupport": {"description": "Section for support/donation links"},
"aboutBuyMeCoffee": "Buy me a coffee",
@@ -617,6 +631,13 @@
"dialogImportPlaylistTitle": "Import Playlist",
"@dialogImportPlaylistTitle": {"description": "Dialog title - import CSV playlist"},
"dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?",
"csvImportTracks": "{count} tracks from CSV",
"@csvImportTracks": {
"description": "Label shown in quality picker for CSV import",
"placeholders": {
"count": {"type": "int"}
}
},
"@dialogImportPlaylistMessage": {
"description": "Dialog message - import playlist confirmation",
"placeholders": {
@@ -1049,6 +1070,26 @@
"@sectionAudioQuality": {"description": "Settings section header"},
"sectionFileSettings": "File Settings",
"@sectionFileSettings": {"description": "Settings section header"},
"sectionLyrics": "Lyrics",
"@sectionLyrics": {"description": "Settings section header"},
"lyricsMode": "Lyrics Mode",
"@lyricsMode": {"description": "Setting - how to save lyrics"},
"lyricsModeDescription": "Choose how lyrics are saved with your downloads",
"@lyricsModeDescription": {"description": "Lyrics mode picker description"},
"lyricsModeEmbed": "Embed in file",
"@lyricsModeEmbed": {"description": "Lyrics mode option - embed in audio file"},
"lyricsModeEmbedSubtitle": "Lyrics stored inside FLAC metadata",
"@lyricsModeEmbedSubtitle": {"description": "Subtitle for embed option"},
"lyricsModeExternal": "External .lrc file",
"@lyricsModeExternal": {"description": "Lyrics mode option - separate LRC file"},
"lyricsModeExternalSubtitle": "Separate .lrc file for players like Samsung Music",
"@lyricsModeExternalSubtitle": {"description": "Subtitle for external option"},
"lyricsModeBoth": "Both",
"@lyricsModeBoth": {"description": "Lyrics mode option - embed and external"},
"lyricsModeBothSubtitle": "Embed and save .lrc file",
"@lyricsModeBothSubtitle": {"description": "Subtitle for both option"},
"sectionColor": "Color",
"@sectionColor": {"description": "Settings section header"},
"sectionTheme": "Theme",
@@ -1131,6 +1172,12 @@
"@trackAudioQuality": {"description": "Metadata label - audio quality"},
"trackReleaseDate": "Release date",
"@trackReleaseDate": {"description": "Metadata label - release date"},
"trackGenre": "Genre",
"@trackGenre": {"description": "Metadata label - music genre"},
"trackLabel": "Label",
"@trackLabel": {"description": "Metadata label - record label"},
"trackCopyright": "Copyright",
"@trackCopyright": {"description": "Metadata label - copyright information"},
"trackDownloaded": "Downloaded",
"@trackDownloaded": {"description": "Metadata label - download date"},
"trackCopyLyrics": "Copy lyrics",
@@ -1320,6 +1367,16 @@
"@qualityHiResFlacMax": {"description": "Quality option - maximum resolution FLAC"},
"qualityHiResFlacMaxSubtitle": "24-bit / up to 192kHz",
"@qualityHiResFlacMaxSubtitle": {"description": "Technical spec for hi-res max"},
"qualityMp3": "MP3",
"@qualityMp3": {"description": "Quality option - MP3 lossy format"},
"qualityMp3Subtitle": "320kbps (converted from FLAC)",
"@qualityMp3Subtitle": {"description": "Technical spec for MP3"},
"enableMp3Option": "Enable MP3 Option",
"@enableMp3Option": {"description": "Setting - enable MP3 quality option"},
"enableMp3OptionSubtitleOn": "MP3 quality option is available",
"@enableMp3OptionSubtitleOn": {"description": "Subtitle when MP3 is enabled"},
"enableMp3OptionSubtitleOff": "Downloads FLAC then converts to 320kbps MP3",
"@enableMp3OptionSubtitleOff": {"description": "Subtitle when MP3 is disabled"},
"qualityNote": "Actual quality depends on track availability from the service",
"@qualityNote": {"description": "Note about quality availability"},
@@ -1459,6 +1516,13 @@
},
"downloadedAlbumSelectToDelete": "Select tracks to delete",
"@downloadedAlbumSelectToDelete": {"description": "Placeholder when nothing selected"},
"downloadedAlbumDiscHeader": "Disc {discNumber}",
"@downloadedAlbumDiscHeader": {
"description": "Header for disc separator in multi-disc albums",
"placeholders": {
"discNumber": {"type": "int", "example": "1"}
}
},
"utilityFunctions": "Utility Functions",
"@utilityFunctions": {"description": "Extension capability - utility functions"},
@@ -1485,5 +1549,80 @@
"placeholders": {
"message": {"type": "String", "description": "Error message"}
}
}
},
"discographyDownload": "Download Discography",
"@discographyDownload": {"description": "Button - download artist discography"},
"discographyDownloadAll": "Download All",
"@discographyDownloadAll": {"description": "Option - download entire discography"},
"discographyDownloadAllSubtitle": "{count} tracks from {albumCount} releases",
"@discographyDownloadAllSubtitle": {
"description": "Subtitle showing total tracks and albums",
"placeholders": {
"count": {"type": "int"},
"albumCount": {"type": "int"}
}
},
"discographyAlbumsOnly": "Albums Only",
"@discographyAlbumsOnly": {"description": "Option - download only albums"},
"discographyAlbumsOnlySubtitle": "{count} tracks from {albumCount} albums",
"@discographyAlbumsOnlySubtitle": {
"description": "Subtitle showing album tracks count",
"placeholders": {
"count": {"type": "int"},
"albumCount": {"type": "int"}
}
},
"discographySinglesOnly": "Singles & EPs Only",
"@discographySinglesOnly": {"description": "Option - download only singles"},
"discographySinglesOnlySubtitle": "{count} tracks from {albumCount} singles",
"@discographySinglesOnlySubtitle": {
"description": "Subtitle showing singles tracks count",
"placeholders": {
"count": {"type": "int"},
"albumCount": {"type": "int"}
}
},
"discographySelectAlbums": "Select Albums...",
"@discographySelectAlbums": {"description": "Option - manually select albums to download"},
"discographySelectAlbumsSubtitle": "Choose specific albums or singles",
"@discographySelectAlbumsSubtitle": {"description": "Subtitle for select albums option"},
"discographyFetchingTracks": "Fetching tracks...",
"@discographyFetchingTracks": {"description": "Progress - fetching album tracks"},
"discographyFetchingAlbum": "Fetching {current} of {total}...",
"@discographyFetchingAlbum": {
"description": "Progress - fetching specific album",
"placeholders": {
"current": {"type": "int"},
"total": {"type": "int"}
}
},
"discographySelectedCount": "{count} selected",
"@discographySelectedCount": {
"description": "Selection count badge",
"placeholders": {
"count": {"type": "int"}
}
},
"discographyDownloadSelected": "Download Selected",
"@discographyDownloadSelected": {"description": "Button - download selected albums"},
"discographyAddedToQueue": "Added {count} tracks to queue",
"@discographyAddedToQueue": {
"description": "Snackbar - tracks added from discography",
"placeholders": {
"count": {"type": "int"}
}
},
"discographySkippedDownloaded": "{added} added, {skipped} already downloaded",
"@discographySkippedDownloaded": {
"description": "Snackbar - with skipped tracks count",
"placeholders": {
"added": {"type": "int"},
"skipped": {"type": "int"}
}
},
"discographyNoAlbums": "No albums available",
"@discographyNoAlbums": {"description": "Error - no albums found for artist"},
"discographyFailedToFetch": "Failed to fetch some albums",
"@discographyFailedToFetch": {"description": "Error - some albums failed to load"}
}
File diff suppressed because it is too large Load Diff
+53 -15
View File
@@ -642,6 +642,20 @@
}
}
},
"artistPopular": "Popular",
"@artistPopular": {
"description": "Section header for popular/top tracks"
},
"artistMonthlyListeners": "{count} monthly listeners",
"@artistMonthlyListeners": {
"description": "Monthly listener count display",
"placeholders": {
"count": {
"type": "String",
"description": "Formatted listener count"
}
}
},
"trackMetadataTitle": "Track Info",
"@trackMetadataTitle": {
"description": "Track metadata screen title"
@@ -1851,27 +1865,15 @@
},
"sectionLanguage": "Language",
"@sectionLanguage": {
"description": "Settings section header for language selection"
"description": "Settings section header for language"
},
"appearanceLanguage": "App Language",
"@appearanceLanguage": {
"description": "Setting title for language selection"
"description": "Language setting title"
},
"appearanceLanguageSubtitle": "Choose your preferred language",
"@appearanceLanguageSubtitle": {
"description": "Subtitle for language setting"
},
"languageSystem": "System Default",
"@languageSystem": {
"description": "Use device system language"
},
"languageEnglish": "English",
"@languageEnglish": {
"description": "English language option"
},
"languageIndonesian": "Bahasa Indonesia",
"@languageIndonesian": {
"description": "Indonesian language option"
"description": "Language setting subtitle"
},
"settingsAppearanceSubtitle": "Theme, colors, display",
"@settingsAppearanceSubtitle": {
@@ -2573,5 +2575,41 @@
"utilityFunctions": "Utility Functions",
"@utilityFunctions": {
"description": "Extension capability - utility functions"
},
"recentTypeArtist": "Artist",
"@recentTypeArtist": {
"description": "Recent access item type - artist"
},
"recentTypeAlbum": "Album",
"@recentTypeAlbum": {
"description": "Recent access item type - album"
},
"recentTypeSong": "Song",
"@recentTypeSong": {
"description": "Recent access item type - song/track"
},
"recentTypePlaylist": "Playlist",
"@recentTypePlaylist": {
"description": "Recent access item type - playlist"
},
"recentPlaylistInfo": "Playlist: {name}",
"@recentPlaylistInfo": {
"description": "Snackbar message when tapping playlist in recent access",
"placeholders": {
"name": {
"type": "String",
"description": "Playlist name"
}
}
},
"errorGeneric": "Error: {message}",
"@errorGeneric": {
"description": "Generic error message format",
"placeholders": {
"message": {
"type": "String",
"description": "Error message"
}
}
}
}
+53 -15
View File
@@ -642,6 +642,20 @@
}
}
},
"artistPopular": "Popular",
"@artistPopular": {
"description": "Section header for popular/top tracks"
},
"artistMonthlyListeners": "{count} monthly listeners",
"@artistMonthlyListeners": {
"description": "Monthly listener count display",
"placeholders": {
"count": {
"type": "String",
"description": "Formatted listener count"
}
}
},
"trackMetadataTitle": "Track Info",
"@trackMetadataTitle": {
"description": "Track metadata screen title"
@@ -1851,27 +1865,15 @@
},
"sectionLanguage": "Language",
"@sectionLanguage": {
"description": "Settings section header for language selection"
"description": "Settings section header for language"
},
"appearanceLanguage": "App Language",
"@appearanceLanguage": {
"description": "Setting title for language selection"
"description": "Language setting title"
},
"appearanceLanguageSubtitle": "Choose your preferred language",
"@appearanceLanguageSubtitle": {
"description": "Subtitle for language setting"
},
"languageSystem": "System Default",
"@languageSystem": {
"description": "Use device system language"
},
"languageEnglish": "English",
"@languageEnglish": {
"description": "English language option"
},
"languageIndonesian": "Bahasa Indonesia",
"@languageIndonesian": {
"description": "Indonesian language option"
"description": "Language setting subtitle"
},
"settingsAppearanceSubtitle": "Theme, colors, display",
"@settingsAppearanceSubtitle": {
@@ -2573,5 +2575,41 @@
"utilityFunctions": "Utility Functions",
"@utilityFunctions": {
"description": "Extension capability - utility functions"
},
"recentTypeArtist": "Artist",
"@recentTypeArtist": {
"description": "Recent access item type - artist"
},
"recentTypeAlbum": "Album",
"@recentTypeAlbum": {
"description": "Recent access item type - album"
},
"recentTypeSong": "Song",
"@recentTypeSong": {
"description": "Recent access item type - song/track"
},
"recentTypePlaylist": "Playlist",
"@recentTypePlaylist": {
"description": "Recent access item type - playlist"
},
"recentPlaylistInfo": "Playlist: {name}",
"@recentPlaylistInfo": {
"description": "Snackbar message when tapping playlist in recent access",
"placeholders": {
"name": {
"type": "String",
"description": "Playlist name"
}
}
},
"errorGeneric": "Error: {message}",
"@errorGeneric": {
"description": "Generic error message format",
"placeholders": {
"message": {
"type": "String",
"description": "Error message"
}
}
}
}
+25 -1
View File
@@ -440,6 +440,11 @@
"qualityHiResFlacSubtitle": "24-bit / hingga 96kHz",
"qualityHiResFlacMax": "Hi-Res FLAC Max",
"qualityHiResFlacMaxSubtitle": "24-bit / hingga 192kHz",
"qualityMp3": "MP3",
"qualityMp3Subtitle": "320kbps (konversi dari FLAC)",
"enableMp3Option": "Aktifkan Opsi MP3",
"enableMp3OptionSubtitleOn": "Opsi kualitas MP3 tersedia",
"enableMp3OptionSubtitleOff": "Unduh FLAC lalu konversi ke MP3 320kbps",
"qualityNote": "Kualitas sebenarnya tergantung ketersediaan lagu dari layanan",
"downloadAskBeforeDownload": "Tanya Sebelum Unduh",
@@ -660,6 +665,7 @@
"downloadedAlbumTapToSelect": "Ketuk lagu untuk memilih",
"downloadedAlbumDeleteCount": "Hapus {count} {count, plural, =1{lagu} other{lagu}}",
"downloadedAlbumSelectToDelete": "Pilih lagu untuk dihapus",
"downloadedAlbumDiscHeader": "Disc {discNumber}",
"folderOrganizationDescription": "Atur file yang diunduh ke dalam folder",
"folderOrganizationNone": "Tidak ada",
@@ -677,5 +683,23 @@
"recentTypePlaylist": "Playlist",
"recentPlaylistInfo": "Playlist: {name}",
"errorGeneric": "Error: {message}"
"errorGeneric": "Error: {message}",
"discographyDownload": "Unduh Diskografi",
"discographyDownloadAll": "Unduh Semua",
"discographyDownloadAllSubtitle": "{count} lagu dari {albumCount} rilis",
"discographyAlbumsOnly": "Album Saja",
"discographyAlbumsOnlySubtitle": "{count} lagu dari {albumCount} album",
"discographySinglesOnly": "Single & EP Saja",
"discographySinglesOnlySubtitle": "{count} lagu dari {albumCount} single",
"discographySelectAlbums": "Pilih Album...",
"discographySelectAlbumsSubtitle": "Pilih album atau single tertentu",
"discographyFetchingTracks": "Mengambil lagu...",
"discographyFetchingAlbum": "Mengambil {current} dari {total}...",
"discographySelectedCount": "{count} dipilih",
"discographyDownloadSelected": "Unduh yang Dipilih",
"discographyAddedToQueue": "Menambahkan {count} lagu ke antrian",
"discographySkippedDownloaded": "{added} ditambahkan, {skipped} sudah diunduh",
"discographyNoAlbums": "Tidak ada album tersedia",
"discographyFailedToFetch": "Gagal mengambil beberapa album"
}
+180 -142
View File
@@ -9,23 +9,23 @@
"@appDescription": {
"description": "App description shown in about page"
},
"navHome": "Home",
"navHome": "ホーム",
"@navHome": {
"description": "Bottom navigation - Home tab"
},
"navHistory": "History",
"navHistory": "履歴",
"@navHistory": {
"description": "Bottom navigation - History tab"
},
"navSettings": "Settings",
"navSettings": "設定",
"@navSettings": {
"description": "Bottom navigation - Settings tab"
},
"navStore": "Store",
"navStore": "ストア",
"@navStore": {
"description": "Bottom navigation - Extension store tab"
},
"homeTitle": "Home",
"homeTitle": "ホーム",
"@homeTitle": {
"description": "Home screen title"
},
@@ -59,7 +59,7 @@
"@historyTitle": {
"description": "History screen title"
},
"historyDownloading": "Downloading ({count})",
"historyDownloading": "ダウンロード中 ({count})",
"@historyDownloading": {
"description": "Tab showing active downloads count",
"placeholders": {
@@ -69,19 +69,19 @@
}
}
},
"historyDownloaded": "Downloaded",
"historyDownloaded": "ダウンロード済み",
"@historyDownloaded": {
"description": "Tab showing completed downloads"
},
"historyFilterAll": "All",
"historyFilterAll": "すべて",
"@historyFilterAll": {
"description": "Filter chip - show all items"
},
"historyFilterAlbums": "Albums",
"historyFilterAlbums": "アルバム",
"@historyFilterAlbums": {
"description": "Filter chip - show albums only"
},
"historyFilterSingles": "Singles",
"historyFilterSingles": "シングル",
"@historyFilterSingles": {
"description": "Filter chip - show singles only"
},
@@ -127,31 +127,31 @@
"@historyNoSinglesSubtitle": {
"description": "Empty state subtitle for singles filter"
},
"settingsTitle": "Settings",
"settingsTitle": "設定",
"@settingsTitle": {
"description": "Settings screen title"
},
"settingsDownload": "Download",
"settingsDownload": "ダウンロード",
"@settingsDownload": {
"description": "Settings section - download options"
},
"settingsAppearance": "Appearance",
"settingsAppearance": "外観",
"@settingsAppearance": {
"description": "Settings section - visual customization"
},
"settingsOptions": "Options",
"settingsOptions": "オプション",
"@settingsOptions": {
"description": "Settings section - app options"
},
"settingsExtensions": "Extensions",
"settingsExtensions": "拡張",
"@settingsExtensions": {
"description": "Settings section - extension management"
},
"settingsAbout": "About",
"settingsAbout": "アプリについて",
"@settingsAbout": {
"description": "Settings section - app info"
},
"downloadTitle": "Download",
"downloadTitle": "ダウンロード",
"@downloadTitle": {
"description": "Download settings page title"
},
@@ -163,19 +163,19 @@
"@downloadLocationSubtitle": {
"description": "Subtitle for download location"
},
"downloadLocationDefault": "Default location",
"downloadLocationDefault": "デフォルトの場所",
"@downloadLocationDefault": {
"description": "Shown when using default folder"
},
"downloadDefaultService": "Default Service",
"downloadDefaultService": "デフォルトのサービス",
"@downloadDefaultService": {
"description": "Setting for preferred download service (Tidal/Qobuz/Amazon)"
},
"downloadDefaultServiceSubtitle": "Service used for downloads",
"downloadDefaultServiceSubtitle": "ダウンロードに使用したサービス",
"@downloadDefaultServiceSubtitle": {
"description": "Subtitle for default service"
},
"downloadDefaultQuality": "Default Quality",
"downloadDefaultQuality": "デフォルトの品質",
"@downloadDefaultQuality": {
"description": "Setting for audio quality"
},
@@ -187,7 +187,7 @@
"@downloadAskQualitySubtitle": {
"description": "Subtitle for ask quality toggle"
},
"downloadFilenameFormat": "Filename Format",
"downloadFilenameFormat": "ファイル名の形式",
"@downloadFilenameFormat": {
"description": "Setting for output filename pattern"
},
@@ -219,27 +219,27 @@
"@quality128": {
"description": "Audio quality option - 128kbps MP3"
},
"appearanceTitle": "Appearance",
"appearanceTitle": "外観",
"@appearanceTitle": {
"description": "Appearance settings page title"
},
"appearanceTheme": "Theme",
"appearanceTheme": "テーマ",
"@appearanceTheme": {
"description": "Theme mode setting"
},
"appearanceThemeSystem": "System",
"appearanceThemeSystem": "システム",
"@appearanceThemeSystem": {
"description": "Follow system theme"
},
"appearanceThemeLight": "Light",
"appearanceThemeLight": "ライト",
"@appearanceThemeLight": {
"description": "Light theme"
},
"appearanceThemeDark": "Dark",
"appearanceThemeDark": "ダーク",
"@appearanceThemeDark": {
"description": "Dark theme"
},
"appearanceDynamicColor": "Dynamic Color",
"appearanceDynamicColor": "ダイナミックカラー",
"@appearanceDynamicColor": {
"description": "Material You dynamic colors"
},
@@ -247,31 +247,31 @@
"@appearanceDynamicColorSubtitle": {
"description": "Subtitle for dynamic color"
},
"appearanceAccentColor": "Accent Color",
"appearanceAccentColor": "アクセントカラー",
"@appearanceAccentColor": {
"description": "Custom accent color picker"
},
"appearanceHistoryView": "History View",
"appearanceHistoryView": "履歴の表示",
"@appearanceHistoryView": {
"description": "Layout style for history"
},
"appearanceHistoryViewList": "List",
"appearanceHistoryViewList": "リスト",
"@appearanceHistoryViewList": {
"description": "List layout option"
},
"appearanceHistoryViewGrid": "Grid",
"appearanceHistoryViewGrid": "グリッド",
"@appearanceHistoryViewGrid": {
"description": "Grid layout option"
},
"optionsTitle": "Options",
"optionsTitle": "オプション",
"@optionsTitle": {
"description": "Options settings page title"
},
"optionsSearchSource": "Search Source",
"optionsSearchSource": "検索ソース",
"@optionsSearchSource": {
"description": "Section for search provider settings"
},
"optionsPrimaryProvider": "Primary Provider",
"optionsPrimaryProvider": "プライマリーのプロバイダー",
"@optionsPrimaryProvider": {
"description": "Main search provider setting"
},
@@ -279,7 +279,7 @@
"@optionsPrimaryProviderSubtitle": {
"description": "Subtitle for primary provider"
},
"optionsUsingExtension": "Using extension: {extensionName}",
"optionsUsingExtension": "拡張の使用: {extensionName}",
"@optionsUsingExtension": {
"description": "Shows active extension name",
"placeholders": {
@@ -300,7 +300,7 @@
"@optionsAutoFallbackSubtitle": {
"description": "Subtitle for auto fallback"
},
"optionsUseExtensionProviders": "Use Extension Providers",
"optionsUseExtensionProviders": "拡張のプロバイダーを使用する",
"@optionsUseExtensionProviders": {
"description": "Enable extension download providers"
},
@@ -308,11 +308,11 @@
"@optionsUseExtensionProvidersOn": {
"description": "Status when extension providers enabled"
},
"optionsUseExtensionProvidersOff": "Using built-in providers only",
"optionsUseExtensionProvidersOff": "内蔵のプロバイダーのみを使用する",
"@optionsUseExtensionProvidersOff": {
"description": "Status when extension providers disabled"
},
"optionsEmbedLyrics": "Embed Lyrics",
"optionsEmbedLyrics": "歌詞を埋め込む",
"@optionsEmbedLyrics": {
"description": "Embed lyrics in audio files"
},
@@ -320,7 +320,7 @@
"@optionsEmbedLyricsSubtitle": {
"description": "Subtitle for embed lyrics"
},
"optionsMaxQualityCover": "Max Quality Cover",
"optionsMaxQualityCover": "最大品質のカバー",
"@optionsMaxQualityCover": {
"description": "Download highest quality album art"
},
@@ -349,7 +349,7 @@
"@optionsConcurrentWarning": {
"description": "Warning about rate limits"
},
"optionsExtensionStore": "Extension Store",
"optionsExtensionStore": "拡張ストア",
"@optionsExtensionStore": {
"description": "Show/hide store tab"
},
@@ -357,7 +357,7 @@
"@optionsExtensionStoreSubtitle": {
"description": "Subtitle for extension store toggle"
},
"optionsCheckUpdates": "Check for Updates",
"optionsCheckUpdates": "更新を確認",
"@optionsCheckUpdates": {
"description": "Auto update check toggle"
},
@@ -365,15 +365,15 @@
"@optionsCheckUpdatesSubtitle": {
"description": "Subtitle for update check"
},
"optionsUpdateChannel": "Update Channel",
"optionsUpdateChannel": "更新チャンネル",
"@optionsUpdateChannel": {
"description": "Stable vs preview releases"
},
"optionsUpdateChannelStable": "Stable releases only",
"optionsUpdateChannelStable": "安定版リリースのみ",
"@optionsUpdateChannelStable": {
"description": "Only stable updates"
},
"optionsUpdateChannelPreview": "Get preview releases",
"optionsUpdateChannelPreview": "プレビューリリースを入手",
"@optionsUpdateChannelPreview": {
"description": "Include beta/preview updates"
},
@@ -401,11 +401,11 @@
"@optionsDetailedLoggingOff": {
"description": "Status when logging disabled"
},
"optionsSpotifyCredentials": "Spotify Credentials",
"optionsSpotifyCredentials": "Spotify の認証情報",
"@optionsSpotifyCredentials": {
"description": "Spotify API credentials setting"
},
"optionsSpotifyCredentialsConfigured": "Client ID: {clientId}...",
"optionsSpotifyCredentialsConfigured": "クライアント ID: {clientId}...",
"@optionsSpotifyCredentialsConfigured": {
"description": "Shows configured client ID preview",
"placeholders": {
@@ -422,23 +422,23 @@
"@optionsSpotifyWarning": {
"description": "Info about Spotify API requirement"
},
"extensionsTitle": "Extensions",
"extensionsTitle": "拡張",
"@extensionsTitle": {
"description": "Extensions page title"
},
"extensionsInstalled": "Installed Extensions",
"extensionsInstalled": "インストール済みの拡張",
"@extensionsInstalled": {
"description": "Section header for installed extensions"
},
"extensionsNone": "No extensions installed",
"extensionsNone": "拡張はインストールされていません",
"@extensionsNone": {
"description": "Empty state title"
},
"extensionsNoneSubtitle": "Install extensions from the Store tab",
"extensionsNoneSubtitle": "ストアタブから拡張をインストール",
"@extensionsNoneSubtitle": {
"description": "Empty state subtitle"
},
"extensionsEnabled": "Enabled",
"extensionsEnabled": "有効",
"@extensionsEnabled": {
"description": "Extension status - active"
},
@@ -446,7 +446,7 @@
"@extensionsDisabled": {
"description": "Extension status - inactive"
},
"extensionsVersion": "Version {version}",
"extensionsVersion": "バージョン {version}",
"@extensionsVersion": {
"description": "Extension version display",
"placeholders": {
@@ -455,7 +455,7 @@
}
}
},
"extensionsAuthor": "by {author}",
"extensionsAuthor": "作者 {author}",
"@extensionsAuthor": {
"description": "Extension author credit",
"placeholders": {
@@ -464,43 +464,43 @@
}
}
},
"extensionsUninstall": "Uninstall",
"extensionsUninstall": "アンインストール",
"@extensionsUninstall": {
"description": "Uninstall extension button"
},
"extensionsSetAsSearch": "Set as Search Provider",
"extensionsSetAsSearch": "検索プロバイダーを設定",
"@extensionsSetAsSearch": {
"description": "Use extension for search"
},
"storeTitle": "Extension Store",
"storeTitle": "拡張ストア",
"@storeTitle": {
"description": "Store screen title"
},
"storeSearch": "Search extensions...",
"storeSearch": "拡張を検索...",
"@storeSearch": {
"description": "Store search placeholder"
},
"storeInstall": "Install",
"storeInstall": "インストール",
"@storeInstall": {
"description": "Install extension button"
},
"storeInstalled": "Installed",
"storeInstalled": "インストール済み",
"@storeInstalled": {
"description": "Already installed badge"
},
"storeUpdate": "Update",
"storeUpdate": "更新",
"@storeUpdate": {
"description": "Update available button"
},
"aboutTitle": "About",
"aboutTitle": "アプリについて",
"@aboutTitle": {
"description": "About page title"
},
"aboutContributors": "Contributors",
"aboutContributors": "貢献者",
"@aboutContributors": {
"description": "Section for contributors"
},
"aboutMobileDeveloper": "Mobile version developer",
"aboutMobileDeveloper": "モバイルバージョンの開発者",
"@aboutMobileDeveloper": {
"description": "Role description for mobile dev"
},
@@ -512,23 +512,23 @@
"@aboutLogoArtist": {
"description": "Role description for logo artist"
},
"aboutSpecialThanks": "Special Thanks",
"aboutSpecialThanks": "スペシャルサンクス",
"@aboutSpecialThanks": {
"description": "Section for special thanks"
},
"aboutLinks": "Links",
"aboutLinks": "リンク",
"@aboutLinks": {
"description": "Section for external links"
},
"aboutMobileSource": "Mobile source code",
"aboutMobileSource": "モバイル版のソースコード",
"@aboutMobileSource": {
"description": "Link to mobile GitHub repo"
},
"aboutPCSource": "PC source code",
"aboutPCSource": "PC 版のソースコード",
"@aboutPCSource": {
"description": "Link to PC GitHub repo"
},
"aboutReportIssue": "Report an issue",
"aboutReportIssue": "Issue で報告する",
"@aboutReportIssue": {
"description": "Link to report bugs"
},
@@ -536,7 +536,7 @@
"@aboutReportIssueSubtitle": {
"description": "Subtitle for report issue"
},
"aboutFeatureRequest": "Feature request",
"aboutFeatureRequest": "機能の要望",
"@aboutFeatureRequest": {
"description": "Link to suggest features"
},
@@ -548,19 +548,19 @@
"@aboutSupport": {
"description": "Section for support/donation links"
},
"aboutBuyMeCoffee": "Buy me a coffee",
"aboutBuyMeCoffee": "コーヒーを買ってください",
"@aboutBuyMeCoffee": {
"description": "Donation link"
},
"aboutBuyMeCoffeeSubtitle": "Support development on Ko-fi",
"aboutBuyMeCoffeeSubtitle": "Ko-fi で開発をサポートします",
"@aboutBuyMeCoffeeSubtitle": {
"description": "Subtitle for donation"
},
"aboutApp": "App",
"aboutApp": "アプリ",
"@aboutApp": {
"description": "Section for app info"
},
"aboutVersion": "Version",
"aboutVersion": "バージョン",
"@aboutVersion": {
"description": "Version info label"
},
@@ -625,11 +625,11 @@
"@artistAlbums": {
"description": "Section header for artist albums"
},
"artistSingles": "Singles & EPs",
"artistSingles": "シングルと EP",
"@artistSingles": {
"description": "Section header for singles/EPs"
},
"artistCompilations": "Compilations",
"artistCompilations": "コンピレーション",
"@artistCompilations": {
"description": "Section header for compilations"
},
@@ -642,6 +642,20 @@
}
}
},
"artistPopular": "Popular",
"@artistPopular": {
"description": "Section header for popular/top tracks"
},
"artistMonthlyListeners": "{count} monthly listeners",
"@artistMonthlyListeners": {
"description": "Monthly listener count display",
"placeholders": {
"count": {
"type": "String",
"description": "Formatted listener count"
}
}
},
"trackMetadataTitle": "Track Info",
"@trackMetadataTitle": {
"description": "Track metadata screen title"
@@ -730,15 +744,15 @@
"@setupChooseFolder": {
"description": "Button to pick folder"
},
"setupContinue": "Continue",
"setupContinue": "続行",
"@setupContinue": {
"description": "Continue to next step button"
},
"setupSkip": "Skip for now",
"setupSkip": "今はスキップ",
"@setupSkip": {
"description": "Skip current step button"
},
"setupStorageAccessRequired": "Storage Access Required",
"setupStorageAccessRequired": "ストレージアクセスが必要です",
"@setupStorageAccessRequired": {
"description": "Title when storage access needed"
},
@@ -841,7 +855,7 @@
"@setupStepSpotify": {
"description": "Setup step indicator - Spotify API"
},
"setupStepPermission": "Permission",
"setupStepPermission": "権限",
"@setupStepPermission": {
"description": "Setup step indicator - permission"
},
@@ -861,7 +875,7 @@
"@setupNotificationGranted": {
"description": "Success message for notification permission"
},
"setupNotificationEnable": "Enable Notifications",
"setupNotificationEnable": "通知を有効化する",
"@setupNotificationEnable": {
"description": "Button to enable notifications"
},
@@ -869,7 +883,7 @@
"@setupNotificationDescription": {
"description": "Explanation for notifications"
},
"setupFolderSelected": "Download Folder Selected!",
"setupFolderSelected": "ダウンロードフォルダが選択済みです!",
"@setupFolderSelected": {
"description": "Success message for folder selection"
},
@@ -889,7 +903,7 @@
"@setupSelectFolder": {
"description": "Button to select folder"
},
"setupSpotifyApiOptional": "Spotify API (Optional)",
"setupSpotifyApiOptional": "Spotify API (任意)",
"@setupSpotifyApiOptional": {
"description": "Spotify API step title"
},
@@ -897,7 +911,7 @@
"@setupSpotifyApiDescription": {
"description": "Explanation for Spotify API"
},
"setupUseSpotifyApi": "Use Spotify API",
"setupUseSpotifyApi": "Spotify API を使用する",
"@setupUseSpotifyApi": {
"description": "Toggle to enable Spotify API"
},
@@ -905,15 +919,15 @@
"@setupEnterCredentialsBelow": {
"description": "Prompt to enter credentials"
},
"setupUsingDeezer": "Using Deezer (no account needed)",
"setupUsingDeezer": "Deezer を使用中 (アカウントは不要です)",
"@setupUsingDeezer": {
"description": "Status when using Deezer"
},
"setupEnterClientId": "Enter Spotify Client ID",
"setupEnterClientId": "Spotify クライアント ID を入力",
"@setupEnterClientId": {
"description": "Placeholder for client ID field"
},
"setupEnterClientSecret": "Enter Spotify Client Secret",
"setupEnterClientSecret": "Spotify クライアントシークレットを入力",
"@setupEnterClientSecret": {
"description": "Placeholder for client secret field"
},
@@ -937,15 +951,15 @@
"@setupNotificationBackgroundDescription": {
"description": "Detailed notification explanation"
},
"setupSkipForNow": "Skip for now",
"setupSkipForNow": "今はスキップ",
"@setupSkipForNow": {
"description": "Skip button text"
},
"setupBack": "Back",
"setupBack": "戻る",
"@setupBack": {
"description": "Back button text"
},
"setupNext": "Next",
"setupNext": "次へ",
"@setupNext": {
"description": "Next button text"
},
@@ -953,7 +967,7 @@
"@setupGetStarted": {
"description": "Final setup button"
},
"setupSkipAndStart": "Skip & Start",
"setupSkipAndStart": "スキップと開始",
"@setupSkipAndStart": {
"description": "Skip setup and start app"
},
@@ -1069,7 +1083,7 @@
"@dialogRemoveExtensionMessage": {
"description": "Dialog message - uninstall confirmation"
},
"dialogUninstallExtension": "Uninstall Extension?",
"dialogUninstallExtension": "拡張をアンインストールしますか?",
"@dialogUninstallExtension": {
"description": "Dialog title - uninstall extension"
},
@@ -1103,7 +1117,7 @@
}
}
},
"dialogImportPlaylistTitle": "Import Playlist",
"dialogImportPlaylistTitle": "プレイリストをインポート",
"@dialogImportPlaylistTitle": {
"description": "Dialog title - import CSV playlist"
},
@@ -1242,7 +1256,7 @@
"@snackbarFailedToUpdate": {
"description": "Snackbar - extension update error"
},
"errorRateLimited": "Rate Limited",
"errorRateLimited": "レート制限",
"@errorRateLimited": {
"description": "Error title - too many requests"
},
@@ -1509,7 +1523,7 @@
}
}
},
"updateDownload": "Download",
"updateDownload": "ダウンロード",
"@updateDownload": {
"description": "Update button - download update"
},
@@ -1537,7 +1551,7 @@
"@updateNewVersionReady": {
"description": "Update subtitle"
},
"updateCurrent": "Current",
"updateCurrent": "現在",
"@updateCurrent": {
"description": "Label for current version"
},
@@ -1669,15 +1683,15 @@
"@logClearLogsMessage": {
"description": "Clear logs confirmation message"
},
"logIspBlocking": "ISP BLOCKING DETECTED",
"logIspBlocking": "ISP のブロックを検出しました",
"@logIspBlocking": {
"description": "Error category - ISP blocking"
},
"logRateLimited": "RATE LIMITED",
"logRateLimited": "レート制限",
"@logRateLimited": {
"description": "Error category - rate limiting"
},
"logNetworkError": "NETWORK ERROR",
"logNetworkError": "ネットワークエラー",
"@logNetworkError": {
"description": "Error category - network issues"
},
@@ -1851,27 +1865,15 @@
},
"sectionLanguage": "Language",
"@sectionLanguage": {
"description": "Settings section header for language selection"
"description": "Settings section header for language"
},
"appearanceLanguage": "App Language",
"@appearanceLanguage": {
"description": "Setting title for language selection"
"description": "Language setting title"
},
"appearanceLanguageSubtitle": "Choose your preferred language",
"@appearanceLanguageSubtitle": {
"description": "Subtitle for language setting"
},
"languageSystem": "System Default",
"@languageSystem": {
"description": "Use device system language"
},
"languageEnglish": "English",
"@languageEnglish": {
"description": "English language option"
},
"languageIndonesian": "Bahasa Indonesia",
"@languageIndonesian": {
"description": "Indonesian language option"
"description": "Language setting subtitle"
},
"settingsAppearanceSubtitle": "Theme, colors, display",
"@settingsAppearanceSubtitle": {
@@ -1939,27 +1941,27 @@
"@trackMetadata": {
"description": "Tab title - track metadata"
},
"trackFileInfo": "File Info",
"trackFileInfo": "ファイル情報",
"@trackFileInfo": {
"description": "Tab title - file information"
},
"trackLyrics": "Lyrics",
"trackLyrics": "歌詞",
"@trackLyrics": {
"description": "Tab title - lyrics"
},
"trackFileNotFound": "File not found",
"trackFileNotFound": "ファイルがありません",
"@trackFileNotFound": {
"description": "Error - file doesn't exist"
},
"trackOpenInDeezer": "Open in Deezer",
"trackOpenInDeezer": "Deezer で開く",
"@trackOpenInDeezer": {
"description": "Action - open track in Deezer app"
},
"trackOpenInSpotify": "Open in Spotify",
"trackOpenInSpotify": "Spotify で開く",
"@trackOpenInSpotify": {
"description": "Action - open track in Spotify app"
},
"trackTrackName": "Track name",
"trackTrackName": "トラック名",
"@trackTrackName": {
"description": "Metadata label - track title"
},
@@ -2131,11 +2133,11 @@
"@extensionDefaultProvider": {
"description": "Default search provider option"
},
"extensionDefaultProviderSubtitle": "Use built-in search",
"extensionDefaultProviderSubtitle": "内蔵の検索を使用する",
"@extensionDefaultProviderSubtitle": {
"description": "Subtitle for default provider"
},
"extensionAuthor": "Author",
"extensionAuthor": "作者",
"@extensionAuthor": {
"description": "Extension detail - author"
},
@@ -2143,7 +2145,7 @@
"@extensionId": {
"description": "Extension detail - unique ID"
},
"extensionError": "Error",
"extensionError": "エラー",
"@extensionError": {
"description": "Extension detail - error message"
},
@@ -2183,19 +2185,19 @@
"@extensionSettings": {
"description": "Section header - extension settings"
},
"extensionRemoveButton": "Remove Extension",
"extensionRemoveButton": "拡張を削除",
"@extensionRemoveButton": {
"description": "Button to uninstall extension"
},
"extensionUpdated": "Updated",
"extensionUpdated": "更新済み",
"@extensionUpdated": {
"description": "Extension detail - last update"
},
"extensionMinAppVersion": "Min App Version",
"extensionMinAppVersion": "最小のアプリバージョン",
"@extensionMinAppVersion": {
"description": "Extension detail - minimum app version"
},
"extensionCustomTrackMatching": "Custom Track Matching",
"extensionCustomTrackMatching": "カスタムトラックマッチング",
"@extensionCustomTrackMatching": {
"description": "Capability - custom track matching algorithm"
},
@@ -2234,11 +2236,11 @@
"@extensionsProviderPrioritySection": {
"description": "Section header - provider priority"
},
"extensionsInstalledSection": "Installed Extensions",
"extensionsInstalledSection": "インストール済みの拡張",
"@extensionsInstalledSection": {
"description": "Section header - installed extensions"
},
"extensionsNoExtensions": "No extensions installed",
"extensionsNoExtensions": "拡張はインストールされていません",
"@extensionsNoExtensions": {
"description": "Empty state - no extensions"
},
@@ -2246,7 +2248,7 @@
"@extensionsNoExtensionsSubtitle": {
"description": "Empty state subtitle"
},
"extensionsInstallButton": "Install Extension",
"extensionsInstallButton": "拡張をインストール",
"@extensionsInstallButton": {
"description": "Button to install extension from file"
},
@@ -2302,7 +2304,7 @@
"@extensionsErrorLoading": {
"description": "Error message when extension fails to load"
},
"qualityFlacLossless": "FLAC Lossless",
"qualityFlacLossless": "FLAC ロスレス",
"@qualityFlacLossless": {
"description": "Quality option - CD quality FLAC"
},
@@ -2310,19 +2312,19 @@
"@qualityFlacLosslessSubtitle": {
"description": "Technical spec for lossless"
},
"qualityHiResFlac": "Hi-Res FLAC",
"qualityHiResFlac": "ハイレゾ FLAC",
"@qualityHiResFlac": {
"description": "Quality option - high resolution FLAC"
},
"qualityHiResFlacSubtitle": "24-bit / up to 96kHz",
"qualityHiResFlacSubtitle": "24-bit / 最大 96kHz",
"@qualityHiResFlacSubtitle": {
"description": "Technical spec for hi-res"
},
"qualityHiResFlacMax": "Hi-Res FLAC Max",
"qualityHiResFlacMax": "ハイレゾ FLAC 最大",
"@qualityHiResFlacMax": {
"description": "Quality option - maximum resolution FLAC"
},
"qualityHiResFlacMaxSubtitle": "24-bit / up to 192kHz",
"qualityHiResFlacMaxSubtitle": "24-bit / 最大 192kHz",
"@qualityHiResFlacMaxSubtitle": {
"description": "Technical spec for hi-res max"
},
@@ -2334,11 +2336,11 @@
"@downloadAskBeforeDownload": {
"description": "Setting - show quality picker"
},
"downloadDirectory": "Download Directory",
"downloadDirectory": "ダウンロードディレクトリ",
"@downloadDirectory": {
"description": "Setting - download folder"
},
"downloadSeparateSinglesFolder": "Separate Singles Folder",
"downloadSeparateSinglesFolder": "シングルのフォルダを分割",
"@downloadSeparateSinglesFolder": {
"description": "Setting - separate folder for singles"
},
@@ -2422,11 +2424,11 @@
"@serviceSpotify": {
"description": "Service name - DO NOT TRANSLATE"
},
"appearanceAmoledDark": "AMOLED Dark",
"appearanceAmoledDark": "AMOLED ダーク",
"@appearanceAmoledDark": {
"description": "Theme option - pure black"
},
"appearanceAmoledDarkSubtitle": "Pure black background",
"appearanceAmoledDarkSubtitle": "ピュアブラックの背景",
"@appearanceAmoledDarkSubtitle": {
"description": "Subtitle for AMOLED dark"
},
@@ -2434,15 +2436,15 @@
"@appearanceChooseAccentColor": {
"description": "Color picker dialog title"
},
"appearanceChooseTheme": "Theme Mode",
"appearanceChooseTheme": "テーマモード",
"@appearanceChooseTheme": {
"description": "Theme picker dialog title"
},
"queueTitle": "Download Queue",
"queueTitle": "ダウンロードキュー",
"@queueTitle": {
"description": "Queue screen title"
},
"queueClearAll": "Clear All",
"queueClearAll": "すべて消去",
"@queueClearAll": {
"description": "Button - clear all queue items"
},
@@ -2573,5 +2575,41 @@
"utilityFunctions": "Utility Functions",
"@utilityFunctions": {
"description": "Extension capability - utility functions"
},
"recentTypeArtist": "Artist",
"@recentTypeArtist": {
"description": "Recent access item type - artist"
},
"recentTypeAlbum": "Album",
"@recentTypeAlbum": {
"description": "Recent access item type - album"
},
"recentTypeSong": "Song",
"@recentTypeSong": {
"description": "Recent access item type - song/track"
},
"recentTypePlaylist": "Playlist",
"@recentTypePlaylist": {
"description": "Recent access item type - playlist"
},
"recentPlaylistInfo": "Playlist: {name}",
"@recentPlaylistInfo": {
"description": "Snackbar message when tapping playlist in recent access",
"placeholders": {
"name": {
"type": "String",
"description": "Playlist name"
}
}
},
"errorGeneric": "Error: {message}",
"@errorGeneric": {
"description": "Generic error message format",
"placeholders": {
"message": {
"type": "String",
"description": "Error message"
}
}
}
}
+53 -15
View File
@@ -642,6 +642,20 @@
}
}
},
"artistPopular": "Popular",
"@artistPopular": {
"description": "Section header for popular/top tracks"
},
"artistMonthlyListeners": "{count} monthly listeners",
"@artistMonthlyListeners": {
"description": "Monthly listener count display",
"placeholders": {
"count": {
"type": "String",
"description": "Formatted listener count"
}
}
},
"trackMetadataTitle": "Track Info",
"@trackMetadataTitle": {
"description": "Track metadata screen title"
@@ -1851,27 +1865,15 @@
},
"sectionLanguage": "Language",
"@sectionLanguage": {
"description": "Settings section header for language selection"
"description": "Settings section header for language"
},
"appearanceLanguage": "App Language",
"@appearanceLanguage": {
"description": "Setting title for language selection"
"description": "Language setting title"
},
"appearanceLanguageSubtitle": "Choose your preferred language",
"@appearanceLanguageSubtitle": {
"description": "Subtitle for language setting"
},
"languageSystem": "System Default",
"@languageSystem": {
"description": "Use device system language"
},
"languageEnglish": "English",
"@languageEnglish": {
"description": "English language option"
},
"languageIndonesian": "Bahasa Indonesia",
"@languageIndonesian": {
"description": "Indonesian language option"
"description": "Language setting subtitle"
},
"settingsAppearanceSubtitle": "Theme, colors, display",
"@settingsAppearanceSubtitle": {
@@ -2573,5 +2575,41 @@
"utilityFunctions": "Utility Functions",
"@utilityFunctions": {
"description": "Extension capability - utility functions"
},
"recentTypeArtist": "Artist",
"@recentTypeArtist": {
"description": "Recent access item type - artist"
},
"recentTypeAlbum": "Album",
"@recentTypeAlbum": {
"description": "Recent access item type - album"
},
"recentTypeSong": "Song",
"@recentTypeSong": {
"description": "Recent access item type - song/track"
},
"recentTypePlaylist": "Playlist",
"@recentTypePlaylist": {
"description": "Recent access item type - playlist"
},
"recentPlaylistInfo": "Playlist: {name}",
"@recentPlaylistInfo": {
"description": "Snackbar message when tapping playlist in recent access",
"placeholders": {
"name": {
"type": "String",
"description": "Playlist name"
}
}
},
"errorGeneric": "Error: {message}",
"@errorGeneric": {
"description": "Generic error message format",
"placeholders": {
"message": {
"type": "String",
"description": "Error message"
}
}
}
}
+53 -15
View File
@@ -642,6 +642,20 @@
}
}
},
"artistPopular": "Popular",
"@artistPopular": {
"description": "Section header for popular/top tracks"
},
"artistMonthlyListeners": "{count} monthly listeners",
"@artistMonthlyListeners": {
"description": "Monthly listener count display",
"placeholders": {
"count": {
"type": "String",
"description": "Formatted listener count"
}
}
},
"trackMetadataTitle": "Track Info",
"@trackMetadataTitle": {
"description": "Track metadata screen title"
@@ -1851,27 +1865,15 @@
},
"sectionLanguage": "Language",
"@sectionLanguage": {
"description": "Settings section header for language selection"
"description": "Settings section header for language"
},
"appearanceLanguage": "App Language",
"@appearanceLanguage": {
"description": "Setting title for language selection"
"description": "Language setting title"
},
"appearanceLanguageSubtitle": "Choose your preferred language",
"@appearanceLanguageSubtitle": {
"description": "Subtitle for language setting"
},
"languageSystem": "System Default",
"@languageSystem": {
"description": "Use device system language"
},
"languageEnglish": "English",
"@languageEnglish": {
"description": "English language option"
},
"languageIndonesian": "Bahasa Indonesia",
"@languageIndonesian": {
"description": "Indonesian language option"
"description": "Language setting subtitle"
},
"settingsAppearanceSubtitle": "Theme, colors, display",
"@settingsAppearanceSubtitle": {
@@ -2573,5 +2575,41 @@
"utilityFunctions": "Utility Functions",
"@utilityFunctions": {
"description": "Extension capability - utility functions"
},
"recentTypeArtist": "Artist",
"@recentTypeArtist": {
"description": "Recent access item type - artist"
},
"recentTypeAlbum": "Album",
"@recentTypeAlbum": {
"description": "Recent access item type - album"
},
"recentTypeSong": "Song",
"@recentTypeSong": {
"description": "Recent access item type - song/track"
},
"recentTypePlaylist": "Playlist",
"@recentTypePlaylist": {
"description": "Recent access item type - playlist"
},
"recentPlaylistInfo": "Playlist: {name}",
"@recentPlaylistInfo": {
"description": "Snackbar message when tapping playlist in recent access",
"placeholders": {
"name": {
"type": "String",
"description": "Playlist name"
}
}
},
"errorGeneric": "Error: {message}",
"@errorGeneric": {
"description": "Generic error message format",
"placeholders": {
"message": {
"type": "String",
"description": "Error message"
}
}
}
}
File diff suppressed because it is too large Load Diff
+613 -575
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
{
"@@locale": "tr",
"@@last_modified": "2026-01-21",
"appName": "SpotiFLAC",
"@appName": {"description": "App name - DO NOT TRANSLATE"}
}
+53 -15
View File
@@ -642,6 +642,20 @@
}
}
},
"artistPopular": "Popular",
"@artistPopular": {
"description": "Section header for popular/top tracks"
},
"artistMonthlyListeners": "{count} monthly listeners",
"@artistMonthlyListeners": {
"description": "Monthly listener count display",
"placeholders": {
"count": {
"type": "String",
"description": "Formatted listener count"
}
}
},
"trackMetadataTitle": "Track Info",
"@trackMetadataTitle": {
"description": "Track metadata screen title"
@@ -1851,27 +1865,15 @@
},
"sectionLanguage": "Language",
"@sectionLanguage": {
"description": "Settings section header for language selection"
"description": "Settings section header for language"
},
"appearanceLanguage": "App Language",
"@appearanceLanguage": {
"description": "Setting title for language selection"
"description": "Language setting title"
},
"appearanceLanguageSubtitle": "Choose your preferred language",
"@appearanceLanguageSubtitle": {
"description": "Subtitle for language setting"
},
"languageSystem": "System Default",
"@languageSystem": {
"description": "Use device system language"
},
"languageEnglish": "English",
"@languageEnglish": {
"description": "English language option"
},
"languageIndonesian": "Bahasa Indonesia",
"@languageIndonesian": {
"description": "Indonesian language option"
"description": "Language setting subtitle"
},
"settingsAppearanceSubtitle": "Theme, colors, display",
"@settingsAppearanceSubtitle": {
@@ -2573,5 +2575,41 @@
"utilityFunctions": "Utility Functions",
"@utilityFunctions": {
"description": "Extension capability - utility functions"
},
"recentTypeArtist": "Artist",
"@recentTypeArtist": {
"description": "Recent access item type - artist"
},
"recentTypeAlbum": "Album",
"@recentTypeAlbum": {
"description": "Recent access item type - album"
},
"recentTypeSong": "Song",
"@recentTypeSong": {
"description": "Recent access item type - song/track"
},
"recentTypePlaylist": "Playlist",
"@recentTypePlaylist": {
"description": "Recent access item type - playlist"
},
"recentPlaylistInfo": "Playlist: {name}",
"@recentPlaylistInfo": {
"description": "Snackbar message when tapping playlist in recent access",
"placeholders": {
"name": {
"type": "String",
"description": "Playlist name"
}
}
},
"errorGeneric": "Error: {message}",
"@errorGeneric": {
"description": "Generic error message format",
"placeholders": {
"message": {
"type": "String",
"description": "Error message"
}
}
}
}
+63 -1
View File
@@ -51,7 +51,7 @@
"@homeSupports": {
"description": "Info text about supported URL types"
},
"homeRecent": "Recent",
"homeRecent": "最新的",
"@homeRecent": {
"description": "Section header for recent searches"
},
@@ -642,6 +642,20 @@
}
}
},
"artistPopular": "Popular",
"@artistPopular": {
"description": "Section header for popular/top tracks"
},
"artistMonthlyListeners": "{count} monthly listeners",
"@artistMonthlyListeners": {
"description": "Monthly listener count display",
"placeholders": {
"count": {
"type": "String",
"description": "Formatted listener count"
}
}
},
"trackMetadataTitle": "Track Info",
"@trackMetadataTitle": {
"description": "Track metadata screen title"
@@ -1849,6 +1863,18 @@
"@sectionLayout": {
"description": "Settings section header"
},
"sectionLanguage": "Language",
"@sectionLanguage": {
"description": "Settings section header for language"
},
"appearanceLanguage": "App Language",
"@appearanceLanguage": {
"description": "Language setting title"
},
"appearanceLanguageSubtitle": "Choose your preferred language",
"@appearanceLanguageSubtitle": {
"description": "Language setting subtitle"
},
"settingsAppearanceSubtitle": "Theme, colors, display",
"@settingsAppearanceSubtitle": {
"description": "Appearance settings description"
@@ -2549,5 +2575,41 @@
"utilityFunctions": "Utility Functions",
"@utilityFunctions": {
"description": "Extension capability - utility functions"
},
"recentTypeArtist": "Artist",
"@recentTypeArtist": {
"description": "Recent access item type - artist"
},
"recentTypeAlbum": "Album",
"@recentTypeAlbum": {
"description": "Recent access item type - album"
},
"recentTypeSong": "Song",
"@recentTypeSong": {
"description": "Recent access item type - song/track"
},
"recentTypePlaylist": "Playlist",
"@recentTypePlaylist": {
"description": "Recent access item type - playlist"
},
"recentPlaylistInfo": "Playlist: {name}",
"@recentPlaylistInfo": {
"description": "Snackbar message when tapping playlist in recent access",
"placeholders": {
"name": {
"type": "String",
"description": "Playlist name"
}
}
},
"errorGeneric": "Error: {message}",
"@errorGeneric": {
"description": "Generic error message format",
"placeholders": {
"message": {
"type": "String",
"description": "Error message"
}
}
}
}
+6
View File
@@ -14,11 +14,17 @@ const int translationThreshold = 70;
/// Only these languages will be available in the app.
const List<Locale> filteredSupportedLocales = <Locale>[
Locale('en'),
Locale('ru'),
Locale('es', 'ES'),
Locale('id'),
Locale('pt', 'PT'),
];
/// Set of locale codes for quick lookup.
const Set<String> filteredLocaleCodes = <String>{
'en',
'ru',
'es_ES',
'id',
'pt_PT',
};
+7 -7
View File
@@ -7,15 +7,18 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/extension_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';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize notification service
await NotificationService().initialize();
await CoverCacheManager.initialize();
debugPrint('CoverCacheManager initialized: ${CoverCacheManager.isInitialized}');
// Initialize share intent service
await ShareIntentService().initialize();
await Future.wait([
NotificationService().initialize(),
ShareIntentService().initialize(),
]);
runApp(
ProviderScope(
@@ -48,11 +51,9 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
final extensionsDir = '${appDir.path}/extensions';
final dataDir = '${appDir.path}/extension_data';
// Create directories if needed
await Directory(extensionsDir).create(recursive: true);
await Directory(dataDir).create(recursive: true);
// Initialize extension system
await ref.read(extensionProvider.notifier).initialize(extensionsDir, dataDir);
} catch (e) {
debugPrint('Failed to initialize extensions: $e');
@@ -61,7 +62,6 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
@override
Widget build(BuildContext context) {
// Eagerly initialize download history provider to load from storage
ref.watch(downloadHistoryProvider);
return widget.child;
}
+6 -9
View File
@@ -3,23 +3,21 @@ import 'package:spotiflac_android/models/track.dart';
part 'download_item.g.dart';
/// Download status enum
enum DownloadStatus {
queued,
downloading,
finalizing, // Embedding metadata, cover, lyrics
finalizing,
completed,
failed,
skipped,
}
/// Error type enum for better error handling
enum DownloadErrorType {
unknown,
notFound, // Track not found on any service
rateLimit, // Rate limited by service
network, // Network/connection error
permission, // File/folder permission error
notFound,
rateLimit,
network,
permission,
}
@JsonSerializable()
@@ -29,7 +27,7 @@ class DownloadItem {
final String service;
final DownloadStatus status;
final double progress;
final double speedMBps; // Download speed in MB/s
final double speedMBps;
final String? filePath;
final String? error;
final DownloadErrorType? errorType;
@@ -78,7 +76,6 @@ class DownloadItem {
);
}
/// Get user-friendly error message based on error type
String get errorMessage {
if (error == null) return '';
+47 -39
View File
@@ -12,25 +12,27 @@ class AppSettings {
final bool embedLyrics;
final bool maxQualityCover;
final bool isFirstLaunch;
final int concurrentDownloads; // 1 = sequential (default), max 3
final bool checkForUpdates; // Check for updates on app start
final String updateChannel; // stable, preview
final bool hasSearchedBefore; // Hide helper text after first search
final String folderOrganization; // none, artist, album, artist_album
final String historyViewMode; // list, grid
final String historyFilterMode; // all, albums, singles
final bool askQualityBeforeDownload; // Show quality picker before each download
final String spotifyClientId; // Custom Spotify client ID (empty = use default)
final String spotifyClientSecret; // Custom Spotify client secret (empty = use default)
final bool useCustomSpotifyCredentials; // Whether to use custom credentials (if set)
final String metadataSource; // spotify, deezer - source for search and metadata
final bool enableLogging; // Enable detailed logging for debugging
final bool useExtensionProviders; // Use extension providers for downloads when available
final String? searchProvider; // null/empty = default (Deezer/Spotify), otherwise extension ID
final bool separateSingles; // Separate singles/EPs into their own folder
final String albumFolderStructure; // artist_album, album_only, artist_year_album, year_album
final bool showExtensionStore; // Show Extension Store tab in navigation
final String locale; // App language: 'system', 'en', 'id', etc.
final int concurrentDownloads;
final bool checkForUpdates;
final String updateChannel;
final bool hasSearchedBefore;
final String folderOrganization;
final String historyViewMode;
final String historyFilterMode;
final bool askQualityBeforeDownload;
final String spotifyClientId;
final String spotifyClientSecret;
final bool useCustomSpotifyCredentials;
final String metadataSource;
final bool enableLogging;
final bool useExtensionProviders;
final String? searchProvider;
final bool separateSingles;
final String albumFolderStructure;
final bool showExtensionStore;
final String locale;
final bool enableMp3Option;
final String lyricsMode;
const AppSettings({
this.defaultService = 'tidal',
@@ -41,25 +43,27 @@ class AppSettings {
this.embedLyrics = true,
this.maxQualityCover = true,
this.isFirstLaunch = true,
this.concurrentDownloads = 1, // Default: sequential (off)
this.checkForUpdates = true, // Default: enabled
this.updateChannel = 'stable', // Default: stable releases only
this.hasSearchedBefore = false, // Default: show helper text
this.folderOrganization = 'none', // Default: no folder organization
this.historyViewMode = 'grid', // Default: grid view
this.historyFilterMode = 'all', // Default: show all
this.askQualityBeforeDownload = true, // Default: ask quality before download
this.spotifyClientId = '', // Default: use built-in credentials
this.spotifyClientSecret = '', // Default: use built-in credentials
this.useCustomSpotifyCredentials = true, // Default: use custom if set
this.metadataSource = 'deezer', // Default: Deezer (no rate limit)
this.enableLogging = false, // Default: disabled for performance
this.useExtensionProviders = true, // Default: use extensions when available
this.searchProvider, // Default: null (use Deezer/Spotify)
this.separateSingles = false, // Default: disabled
this.albumFolderStructure = 'artist_album', // Default: Albums/Artist/Album
this.showExtensionStore = true, // Default: show store
this.locale = 'system', // Default: follow system language
this.concurrentDownloads = 1,
this.checkForUpdates = true,
this.updateChannel = 'stable',
this.hasSearchedBefore = false,
this.folderOrganization = 'none',
this.historyViewMode = 'grid',
this.historyFilterMode = 'all',
this.askQualityBeforeDownload = true,
this.spotifyClientId = '',
this.spotifyClientSecret = '',
this.useCustomSpotifyCredentials = true,
this.metadataSource = 'deezer',
this.enableLogging = false,
this.useExtensionProviders = true,
this.searchProvider,
this.separateSingles = false,
this.albumFolderStructure = 'artist_album',
this.showExtensionStore = true,
this.locale = 'system',
this.enableMp3Option = false,
this.lyricsMode = 'embed',
});
AppSettings copyWith({
@@ -86,11 +90,13 @@ class AppSettings {
bool? enableLogging,
bool? useExtensionProviders,
String? searchProvider,
bool clearSearchProvider = false, // Set to true to clear searchProvider to null
bool clearSearchProvider = false,
bool? separateSingles,
String? albumFolderStructure,
bool? showExtensionStore,
String? locale,
bool? enableMp3Option,
String? lyricsMode,
}) {
return AppSettings(
defaultService: defaultService ?? this.defaultService,
@@ -120,6 +126,8 @@ class AppSettings {
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
locale: locale ?? this.locale,
enableMp3Option: enableMp3Option ?? this.enableMp3Option,
lyricsMode: lyricsMode ?? this.lyricsMode,
);
}
+4
View File
@@ -36,6 +36,8 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
json['albumFolderStructure'] as String? ?? 'artist_album',
showExtensionStore: json['showExtensionStore'] as bool? ?? true,
locale: json['locale'] as String? ?? 'system',
enableMp3Option: json['enableMp3Option'] as bool? ?? false,
lyricsMode: json['lyricsMode'] as String? ?? 'embed',
);
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
@@ -67,4 +69,6 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
'albumFolderStructure': instance.albumFolderStructure,
'showExtensionStore': instance.showExtensionStore,
'locale': instance.locale,
'enableMp3Option': instance.enableMp3Option,
'lyricsMode': instance.lyricsMode,
};
-6
View File
@@ -9,7 +9,6 @@ const String kUseAmoledKey = 'use_amoled';
/// Default Spotify green color for fallback
const int kDefaultSeedColor = 0xFF1DB954;
/// Theme settings model for Material Expressive 3
class ThemeSettings {
final ThemeMode themeMode;
final bool useDynamicColor;
@@ -23,10 +22,8 @@ class ThemeSettings {
this.useAmoled = false,
});
/// Get seed color as Color object
Color get seedColor => Color(seedColorValue);
/// Create a copy with updated values
ThemeSettings copyWith({
ThemeMode? themeMode,
bool? useDynamicColor,
@@ -41,7 +38,6 @@ class ThemeSettings {
);
}
/// Convert to JSON map for persistence
Map<String, dynamic> toJson() => {
kThemeModeKey: themeMode.name,
kUseDynamicColorKey: useDynamicColor,
@@ -49,7 +45,6 @@ class ThemeSettings {
kUseAmoledKey: useAmoled,
};
/// Create from JSON map
factory ThemeSettings.fromJson(Map<String, dynamic> json) {
return ThemeSettings(
themeMode: _themeModeFromString(json[kThemeModeKey] as String?),
@@ -74,7 +69,6 @@ class ThemeSettings {
themeMode.hashCode ^ useDynamicColor.hashCode ^ seedColorValue.hashCode ^ useAmoled.hashCode;
}
/// Helper to convert string to ThemeMode
ThemeMode _themeModeFromString(String? value) {
if (value == null) return ThemeMode.system;
return ThemeMode.values.firstWhere(
+3 -10
View File
@@ -2,7 +2,6 @@ import 'package:json_annotation/json_annotation.dart';
part 'track.g.dart';
/// Track model representing a music track
@JsonSerializable()
class Track {
final String id;
@@ -18,9 +17,9 @@ class Track {
final String? releaseDate;
final String? deezerId;
final ServiceAvailability? availability;
final String? source; // Extension ID that provided this track (null for built-in sources)
final String? albumType; // album, single, ep, compilation (from metadata API)
final String? itemType; // track, album, playlist - for extension search results
final String? source;
final String? albumType;
final String? itemType;
const Track({
required this.id,
@@ -41,25 +40,19 @@ class Track {
this.itemType,
});
/// Check if this track is a single (based on album_type metadata)
bool get isSingle => albumType == 'single' || albumType == 'ep';
/// Check if this is an album item (not a track)
bool get isAlbumItem => itemType == 'album';
/// Check if this is a playlist item (not a track)
bool get isPlaylistItem => itemType == 'playlist';
/// Check if this is an artist item (not a track)
bool get isArtistItem => itemType == 'artist';
/// Check if this is a collection (album, playlist, or artist)
bool get isCollection => isAlbumItem || isPlaylistItem || isArtistItem;
factory Track.fromJson(Map<String, dynamic> json) => _$TrackFromJson(json);
Map<String, dynamic> toJson() => _$TrackToJson(this);
/// Check if this track is from an extension
bool get isFromExtension => source != null && source!.isNotEmpty;
}
File diff suppressed because it is too large Load Diff
+230
View File
@@ -0,0 +1,230 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
final _log = AppLogger('ExploreProvider');
/// Represents an item in a Spotify home section
class ExploreItem {
final String id;
final String uri;
final String type; // track, album, playlist, artist, station
final String name;
final String artists;
final String? description;
final String? coverUrl;
final String? providerId;
final String? albumId;
final String? albumName;
final int durationMs;
const ExploreItem({
required this.id,
required this.uri,
required this.type,
required this.name,
required this.artists,
this.description,
this.coverUrl,
this.providerId,
this.albumId,
this.albumName,
this.durationMs = 0,
});
factory ExploreItem.fromJson(Map<String, dynamic> json) {
return ExploreItem(
id: json['id'] as String? ?? '',
uri: json['uri'] as String? ?? '',
type: json['type'] as String? ?? 'track',
name: json['name'] as String? ?? '',
artists: json['artists'] as String? ?? '',
description: json['description'] as String?,
coverUrl: json['cover_url'] as String?,
providerId: json['provider_id'] as String?,
albumId: json['album_id'] as String?,
albumName: json['album_name'] as String?,
durationMs: json['duration_ms'] as int? ?? 0,
);
}
}
/// Represents a section in Spotify home feed
class ExploreSection {
final String uri;
final String title;
final List<ExploreItem> items;
const ExploreSection({
required this.uri,
required this.title,
required this.items,
});
factory ExploreSection.fromJson(Map<String, dynamic> json) {
final itemsList = json['items'] as List<dynamic>? ?? [];
return ExploreSection(
uri: json['uri'] as String? ?? '',
title: json['title'] as String? ?? '',
items: itemsList
.map((item) => ExploreItem.fromJson(item as Map<String, dynamic>))
.toList(),
);
}
}
/// State for explore/home feed
class ExploreState {
final bool isLoading;
final String? error;
final String? greeting;
final List<ExploreSection> sections;
final DateTime? lastFetched;
const ExploreState({
this.isLoading = false,
this.error,
this.greeting,
this.sections = const [],
this.lastFetched,
});
bool get hasContent => sections.isNotEmpty;
ExploreState copyWith({
bool? isLoading,
String? error,
String? greeting,
List<ExploreSection>? sections,
DateTime? lastFetched,
}) {
return ExploreState(
isLoading: isLoading ?? this.isLoading,
error: error,
greeting: greeting ?? this.greeting,
sections: sections ?? this.sections,
lastFetched: lastFetched ?? this.lastFetched,
);
}
}
/// Provider for explore/home feed state
class ExploreNotifier extends Notifier<ExploreState> {
@override
ExploreState build() {
return const ExploreState();
}
/// Fetch home feed from spotify-web extension
Future<void> fetchHomeFeed({bool forceRefresh = false}) async {
_log.i('fetchHomeFeed called, forceRefresh=$forceRefresh');
// Don't refetch if we have data and it's less than 5 minutes old
if (!forceRefresh &&
state.hasContent &&
state.lastFetched != null &&
DateTime.now().difference(state.lastFetched!).inMinutes < 5) {
_log.d('Using cached home feed');
return;
}
if (state.isLoading) {
_log.d('Home feed fetch already in progress');
return;
}
state = state.copyWith(isLoading: true, error: null);
try {
// Find any extension with homeFeed capability
final extState = ref.read(extensionProvider);
_log.d('Extensions count: ${extState.extensions.length}');
// Look for extensions with homeFeed capability (prefer spotify-web)
Extension? targetExt;
for (final extension in extState.extensions) {
if (!extension.enabled || !extension.hasHomeFeed) {
continue;
}
if (targetExt == null || extension.id == 'spotify-web') {
targetExt = extension;
if (extension.id == 'spotify-web') {
break;
}
}
}
if (targetExt == null) {
_log.w('No extension with homeFeed capability found');
state = state.copyWith(
isLoading: false,
error: 'No extension with home feed support enabled',
);
return;
}
_log.i('Fetching home feed from ${targetExt.id}...');
final result = await PlatformBridge.getExtensionHomeFeed(targetExt.id);
if (result == null) {
state = state.copyWith(
isLoading: false,
error: 'Failed to fetch home feed',
);
return;
}
final success = result['success'] as bool? ?? false;
_log.d('getExtensionHomeFeed success=$success');
if (!success) {
final error = result['error'] as String? ?? 'Unknown error';
state = state.copyWith(
isLoading: false,
error: error,
);
return;
}
final greeting = result['greeting'] as String?;
final sectionsData = result['sections'] as List<dynamic>? ?? [];
final sections = sectionsData
.map((s) => ExploreSection.fromJson(s as Map<String, dynamic>))
.toList();
_log.i('Fetched ${sections.length} sections');
// Debug: log first section items
if (sections.isNotEmpty && sections.first.items.isNotEmpty) {
final firstItem = sections.first.items.first;
_log.d('First item: name=${firstItem.name}, artists=${firstItem.artists}, type=${firstItem.type}');
}
state = ExploreState(
isLoading: false,
greeting: greeting,
sections: sections,
lastFetched: DateTime.now(),
);
} catch (e, stack) {
_log.e('Error fetching home feed: $e', e, stack);
state = state.copyWith(
isLoading: false,
error: e.toString(),
);
}
}
/// Clear cached data
void clear() {
state = const ExploreState();
}
/// Refresh home feed
Future<void> refresh() => fetchHomeFeed(forceRefresh: true);
}
final exploreProvider = NotifierProvider<ExploreNotifier, ExploreState>(() {
return ExploreNotifier();
});
+24 -46
View File
@@ -5,7 +5,6 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
final _log = AppLogger('ExtensionProvider');
/// Represents an installed extension
class Extension {
final String id;
final String name;
@@ -14,19 +13,20 @@ class Extension {
final String author;
final String description;
final bool enabled;
final String status; // 'loaded', 'error', 'disabled'
final String status;
final String? errorMessage;
final String? iconPath; // Path to extension icon
final String? iconPath;
final List<String> permissions;
final List<ExtensionSetting> settings;
final List<QualityOption> qualityOptions; // Custom quality options for download providers
final List<QualityOption> qualityOptions;
final bool hasMetadataProvider;
final bool hasDownloadProvider;
final bool skipMetadataEnrichment; // If true, use metadata from extension instead of enriching
final SearchBehavior? searchBehavior; // Custom search behavior
final URLHandler? urlHandler; // Custom URL handling
final TrackMatching? trackMatching; // Custom track matching
final PostProcessing? postProcessing; // Post-processing hooks
final SearchBehavior? searchBehavior;
final URLHandler? urlHandler;
final TrackMatching? trackMatching;
final PostProcessing? postProcessing;
final Map<String, dynamic> capabilities; // Extension capabilities (homeFeed, browseCategories, etc.)
const Extension({
required this.id,
@@ -49,6 +49,7 @@ class Extension {
this.urlHandler,
this.trackMatching,
this.postProcessing,
this.capabilities = const {},
});
factory Extension.fromJson(Map<String, dynamic> json) {
@@ -85,6 +86,7 @@ class Extension {
postProcessing: json['post_processing'] != null
? PostProcessing.fromJson(json['post_processing'] as Map<String, dynamic>)
: null,
capabilities: (json['capabilities'] as Map<String, dynamic>?) ?? const {},
);
}
@@ -109,6 +111,7 @@ class Extension {
URLHandler? urlHandler,
TrackMatching? trackMatching,
PostProcessing? postProcessing,
Map<String, dynamic>? capabilities,
}) {
return Extension(
id: id ?? this.id,
@@ -131,6 +134,7 @@ class Extension {
urlHandler: urlHandler ?? this.urlHandler,
trackMatching: trackMatching ?? this.trackMatching,
postProcessing: postProcessing ?? this.postProcessing,
capabilities: capabilities ?? this.capabilities,
);
}
@@ -138,9 +142,10 @@ class Extension {
bool get hasURLHandler => urlHandler?.enabled ?? false;
bool get hasCustomMatching => trackMatching?.customMatching ?? false;
bool get hasPostProcessing => postProcessing?.enabled ?? false;
bool get hasHomeFeed => capabilities['homeFeed'] == true;
bool get hasBrowseCategories => capabilities['browseCategories'] == true;
}
/// Custom search behavior configuration
class SearchBehavior {
final bool enabled;
final String? placeholder;
@@ -172,15 +177,11 @@ class SearchBehavior {
);
}
/// Get thumbnail size based on configuration
/// Returns (width, height) tuple
(double, double) getThumbnailSize({double defaultSize = 56}) {
// If custom dimensions specified, use them
if (thumbnailWidth != null && thumbnailHeight != null) {
return (thumbnailWidth!.toDouble(), thumbnailHeight!.toDouble());
}
// Otherwise use ratio presets
switch (thumbnailRatio) {
case 'wide': // 16:9 - YouTube style
return (defaultSize * 16 / 9, defaultSize);
@@ -193,11 +194,10 @@ class SearchBehavior {
}
}
/// Custom track matching configuration
class TrackMatching {
final bool customMatching;
final String? strategy; // "isrc", "name", "duration", "custom"
final int durationTolerance; // in seconds
final String? strategy;
final int durationTolerance;
const TrackMatching({
required this.customMatching,
@@ -214,7 +214,6 @@ class TrackMatching {
}
}
/// Post-processing configuration
class PostProcessing {
final bool enabled;
final List<PostProcessingHook> hooks;
@@ -264,7 +263,6 @@ class URLHandler {
}
}
/// A post-processing hook
class PostProcessingHook {
final String id;
final String name;
@@ -291,12 +289,11 @@ class PostProcessingHook {
}
}
/// Represents a quality option for download providers
class QualityOption {
final String id;
final String label;
final String? description;
final List<QualitySpecificSetting> settings; // Quality-specific settings
final List<QualitySpecificSetting> settings;
const QualityOption({
required this.id,
@@ -317,14 +314,13 @@ class QualityOption {
}
}
/// Represents a setting that's specific to a quality option
class QualitySpecificSetting {
final String key;
final String label;
final String type; // 'string', 'number', 'boolean', 'select'
final String type;
final dynamic defaultValue;
final String? description;
final List<String>? options; // For select type
final List<String>? options;
final bool required;
final bool secret;
@@ -353,15 +349,15 @@ class QualitySpecificSetting {
}
}
/// Represents a setting field for an extension
class ExtensionSetting {
final String key;
final String label;
final String type; // 'string', 'number', 'boolean', 'select'
final String type;
final dynamic defaultValue;
final String? description;
final List<String>? options; // For select type
final List<String>? options;
final bool required;
final String? action;
const ExtensionSetting({
required this.key,
@@ -371,6 +367,7 @@ class ExtensionSetting {
this.description,
this.options,
this.required = false,
this.action,
});
factory ExtensionSetting.fromJson(Map<String, dynamic> json) {
@@ -382,11 +379,11 @@ class ExtensionSetting {
description: json['description'] as String?,
options: (json['options'] as List<dynamic>?)?.cast<String>(),
required: json['required'] as bool? ?? false,
action: json['action'] as String?,
);
}
}
/// State for extension management
class ExtensionState {
final List<Extension> extensions;
final List<String> providerPriority;
@@ -424,7 +421,6 @@ class ExtensionState {
}
/// Provider for managing extensions
class ExtensionNotifier extends Notifier<ExtensionState> {
@override
ExtensionState build() {
@@ -450,7 +446,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
}
/// Load all extensions from directory
Future<void> loadExtensions(String dirPath) async {
state = state.copyWith(isLoading: true, error: null);
@@ -485,12 +480,10 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
}
/// Clear any error state
void clearError() {
state = state.copyWith(error: null);
}
/// Install extension from file (auto-upgrades if already installed with newer version)
Future<bool> installExtension(String filePath) async {
state = state.copyWith(isLoading: true, error: null);
@@ -507,8 +500,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
}
/// Check if a package file is an upgrade for an existing extension
/// Returns: {extension_id, current_version, new_version, can_upgrade, is_installed}
Future<Map<String, dynamic>> checkExtensionUpgrade(String filePath) async {
try {
return await PlatformBridge.checkExtensionUpgrade(filePath);
@@ -518,7 +509,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
}
/// Upgrade an existing extension from a new package file
Future<bool> upgradeExtension(String filePath) async {
state = state.copyWith(isLoading: true, error: null);
@@ -552,16 +542,13 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
}
/// Enable or disable an extension
Future<void> setExtensionEnabled(String extensionId, bool enabled) async {
try {
await PlatformBridge.setExtensionEnabled(extensionId, enabled);
_log.d('Set extension $extensionId enabled: $enabled');
// Get extension info before updating state
final ext = state.extensions.where((e) => e.id == extensionId).firstOrNull;
// Update local state
final extensions = state.extensions.map((e) {
if (e.id == extensionId) {
return e.copyWith(enabled: enabled);
@@ -571,18 +558,15 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
state = state.copyWith(extensions: extensions);
// If disabling an extension, reset related settings
if (!enabled && ext != null) {
final settings = ref.read(settingsProvider);
// If this extension was the search provider, clear it and reset to Deezer
if (settings.searchProvider == extensionId) {
ref.read(settingsProvider.notifier).setSearchProvider(null);
ref.read(settingsProvider.notifier).setMetadataSource('deezer');
_log.d('Cleared search provider and reset to Deezer because extension $extensionId was disabled');
}
// If this extension was the default download service, reset to Tidal
if (ext.hasDownloadProvider && settings.defaultService == extensionId) {
ref.read(settingsProvider.notifier).setDefaultService('tidal');
_log.d('Reset default service to Tidal because extension $extensionId was disabled');
@@ -604,7 +588,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
}
/// Update settings for an extension
Future<void> setExtensionSettings(String extensionId, Map<String, dynamic> settings) async {
try {
await PlatformBridge.setExtensionSettings(extensionId, settings);
@@ -625,7 +608,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
}
/// Set provider priority order
Future<void> setProviderPriority(List<String> priority) async {
try {
await PlatformBridge.setProviderPriority(priority);
@@ -647,7 +629,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
}
/// Set metadata provider priority order
Future<void> setMetadataProviderPriority(List<String> priority) async {
try {
await PlatformBridge.setMetadataProviderPriority(priority);
@@ -669,7 +650,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
}
/// Get extension by ID
Extension? getExtension(String extensionId) {
try {
return state.extensions.firstWhere((ext) => ext.id == extensionId);
@@ -683,7 +663,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
return state.extensions.where((ext) => ext.enabled).toList();
}
/// Get all download providers (built-in + extensions)
List<String> getAllDownloadProviders() {
final providers = ['tidal', 'qobuz', 'amazon'];
for (final ext in state.extensions) {
@@ -704,7 +683,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
return providers;
}
/// Get all extensions that provide custom search
List<Extension> get searchProviders {
return state.extensions.where((ext) => ext.enabled && ext.hasCustomSearch).toList();
}
+40 -18
View File
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
const _recentAccessKey = 'recent_access_history';
const _hiddenDownloadsKey = 'hidden_downloads_in_recents';
const _maxRecentItems = 20;
/// Types of items that can be accessed
@@ -75,19 +76,23 @@ class RecentAccessItem {
/// State for recent access history
class RecentAccessState {
final List<RecentAccessItem> items;
final Set<String> hiddenDownloadIds; // IDs of downloads hidden from recents
final bool isLoaded;
const RecentAccessState({
this.items = const [],
this.hiddenDownloadIds = const {},
this.isLoaded = false,
});
RecentAccessState copyWith({
List<RecentAccessItem>? items,
Set<String>? hiddenDownloadIds,
bool? isLoaded,
}) {
return RecentAccessState(
items: items ?? this.items,
hiddenDownloadIds: hiddenDownloadIds ?? this.hiddenDownloadIds,
isLoaded: isLoaded ?? this.isLoaded,
);
}
@@ -104,20 +109,26 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
Future<void> _loadHistory() async {
final prefs = await SharedPreferences.getInstance();
final json = prefs.getString(_recentAccessKey);
final hiddenJson = prefs.getStringList(_hiddenDownloadsKey);
List<RecentAccessItem> items = [];
Set<String> hiddenIds = {};
if (json != null) {
try {
final List<dynamic> decoded = jsonDecode(json);
final items = decoded
items = decoded
.map((e) => RecentAccessItem.fromJson(e as Map<String, dynamic>))
.toList();
state = state.copyWith(items: items, isLoaded: true);
} catch (e) {
// Invalid JSON, start fresh
state = state.copyWith(isLoaded: true);
}
} else {
state = state.copyWith(isLoaded: true);
}
if (hiddenJson != null) {
hiddenIds = hiddenJson.toSet();
}
state = state.copyWith(items: items, hiddenDownloadIds: hiddenIds, isLoaded: true);
}
Future<void> _saveHistory() async {
@@ -126,6 +137,11 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
await prefs.setString(_recentAccessKey, json);
}
Future<void> _saveHiddenDownloads() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setStringList(_hiddenDownloadsKey, state.hiddenDownloadIds.toList());
}
/// Record an access to an artist
void recordArtistAccess({
required String id,
@@ -201,29 +217,18 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
}
void _recordAccess(RecentAccessItem item) {
// Debug log
// ignore: avoid_print
print('[RecentAccess] Recording: ${item.type.name} - ${item.name} (${item.id})');
// Remove any existing entry with same unique key
final updatedItems = state.items
.where((e) => e.uniqueKey != item.uniqueKey)
.toList();
// Add new item at the beginning
updatedItems.insert(0, item);
// Limit to max items
if (updatedItems.length > _maxRecentItems) {
updatedItems.removeRange(_maxRecentItems, updatedItems.length);
}
state = state.copyWith(items: updatedItems);
_saveHistory();
// Debug log
// ignore: avoid_print
print('[RecentAccess] Total items now: ${updatedItems.length}');
}
/// Remove a specific item from history
@@ -235,14 +240,31 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
_saveHistory();
}
/// Hide a download item from recents (without deleting the actual download)
void hideDownloadFromRecents(String downloadId) {
final updatedHidden = {...state.hiddenDownloadIds, downloadId};
state = state.copyWith(hiddenDownloadIds: updatedHidden);
_saveHiddenDownloads();
}
/// Check if a download is hidden from recents
bool isDownloadHidden(String downloadId) {
return state.hiddenDownloadIds.contains(downloadId);
}
/// Clear all history
void clearHistory() {
state = state.copyWith(items: []);
_saveHistory();
}
/// Clear hidden downloads (show all again)
void clearHiddenDownloads() {
state = state.copyWith(hiddenDownloadIds: {});
_saveHiddenDownloads();
}
}
/// Provider instance
final recentAccessProvider = NotifierProvider<RecentAccessNotifier, RecentAccessState>(
RecentAccessNotifier.new,
);
+16 -14
View File
@@ -22,32 +22,24 @@ class SettingsNotifier extends Notifier<AppSettings> {
if (json != null) {
state = AppSettings.fromJson(jsonDecode(json));
// Run migrations if needed
await _runMigrations(prefs);
// Apply Spotify credentials to Go backend on load
_applySpotifyCredentials();
// Sync logging state
LogBuffer.loggingEnabled = state.enableLogging;
}
}
/// Run one-time migrations for settings
Future<void> _runMigrations(SharedPreferences prefs) async {
final lastMigration = prefs.getInt(_migrationVersionKey) ?? 0;
if (lastMigration < 1) {
// Migration 1: Set metadataSource to 'deezer' for existing users
// Only apply if user hasn't enabled custom Spotify credentials
// (users with custom credentials likely prefer Spotify)
if (!state.useCustomSpotifyCredentials) {
state = state.copyWith(metadataSource: 'deezer');
await _saveSettings();
}
}
// Save current migration version
if (lastMigration < _currentMigrationVersion) {
await prefs.setInt(_migrationVersionKey, _currentMigrationVersion);
}
@@ -58,9 +50,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
await prefs.setString(_settingsKey, jsonEncode(state.toJson()));
}
/// Apply current Spotify credentials to Go backend
Future<void> _applySpotifyCredentials() async {
// Only apply if both fields are set
if (state.spotifyClientId.isNotEmpty &&
state.spotifyClientSecret.isNotEmpty) {
await PlatformBridge.setSpotifyCredentials(
@@ -68,8 +58,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
state.spotifyClientSecret,
);
}
// Note: If credentials are empty, Spotify API will return error
// User should use Deezer as metadata source instead
}
void setDefaultService(String service) {
@@ -102,6 +90,13 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setLyricsMode(String mode) {
if (mode == 'embed' || mode == 'external' || mode == 'both') {
state = state.copyWith(lyricsMode: mode);
_saveSettings();
}
}
void setMaxQualityCover(bool enabled) {
state = state.copyWith(maxQualityCover: enabled);
_saveSettings();
@@ -113,7 +108,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
}
void setConcurrentDownloads(int count) {
// Clamp between 1 and 3
final clamped = count.clamp(1, 3);
state = state.copyWith(concurrentDownloads: clamped);
_saveSettings();
@@ -207,7 +201,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
void setEnableLogging(bool enabled) {
state = state.copyWith(enableLogging: enabled);
_saveSettings();
// Sync logging state to LogBuffer
LogBuffer.loggingEnabled = enabled;
}
@@ -235,6 +228,15 @@ class SettingsNotifier extends Notifier<AppSettings> {
state = state.copyWith(locale: locale);
_saveSettings();
}
void setEnableMp3Option(bool enabled) {
state = state.copyWith(enableMp3Option: enabled);
// If MP3 is disabled and current quality is MP3, reset to LOSSLESS
if (!enabled && state.audioQuality == 'MP3') {
state = state.copyWith(audioQuality: 'LOSSLESS');
}
_saveSettings();
}
}
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
-7
View File
@@ -52,7 +52,6 @@ class StoreCategory {
}
}
/// Represents an extension in the store
class StoreExtension {
final String id;
final String name;
@@ -118,7 +117,6 @@ class StoreExtension {
}
}
/// State for extension store
class StoreState {
final List<StoreExtension> extensions;
final String? selectedCategory;
@@ -200,7 +198,6 @@ class StoreNotifier extends Notifier<StoreState> {
return const StoreState();
}
/// Initialize the store
Future<void> initialize(String cacheDir) async {
if (state.isInitialized) return;
@@ -234,7 +231,6 @@ class StoreNotifier extends Notifier<StoreState> {
}
}
/// Set category filter
void setCategory(String? category) {
if (category == null) {
state = state.copyWith(clearCategory: true);
@@ -248,7 +244,6 @@ class StoreNotifier extends Notifier<StoreState> {
state = state.copyWith(searchQuery: query);
}
/// Clear search
void clearSearch() {
state = state.copyWith(searchQuery: '', clearCategory: true);
}
@@ -279,7 +274,6 @@ class StoreNotifier extends Notifier<StoreState> {
}
}
/// Update an installed extension
Future<bool> updateExtension(String extensionId, String tempDir) async {
state = state.copyWith(isDownloading: true, downloadingId: extensionId, clearError: true);
@@ -305,7 +299,6 @@ class StoreNotifier extends Notifier<StoreState> {
}
}
/// Clear error
void clearError() {
state = state.copyWith(clearError: true);
}
-1
View File
@@ -34,7 +34,6 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
);
} catch (e) {
debugPrint('Error loading theme settings: $e');
// Keep default state on error
}
}
+8 -51
View File
@@ -89,7 +89,6 @@ class TrackState {
}
}
/// Represents an album in artist discography
class ArtistAlbum {
final String id;
final String name;
@@ -112,7 +111,6 @@ class ArtistAlbum {
});
}
/// Represents an artist in search results
class SearchArtist {
final String id;
final String name;
@@ -130,7 +128,6 @@ class SearchArtist {
}
class TrackNotifier extends Notifier<TrackState> {
/// Request ID to track and cancel outdated requests
int _currentRequestId = 0;
@override
@@ -142,14 +139,11 @@ class TrackNotifier extends Notifier<TrackState> {
bool _isRequestValid(int requestId) => requestId == _currentRequestId;
Future<void> fetchFromUrl(String url, {bool useDeezerFallback = true}) async {
// Increment request ID to cancel any pending requests
final requestId = ++_currentRequestId;
// Preserve hasSearchText during fetch
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
try {
// First, check if any extension can handle this URL
final extensionHandler = await PlatformBridge.findURLHandler(url);
if (extensionHandler != null) {
_log.i('Found extension URL handler: $extensionHandler for URL: $url');
@@ -188,7 +182,6 @@ class TrackNotifier extends Notifier<TrackState> {
final albumsList = artistData['albums'] as List<dynamic>? ?? [];
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
// Parse top tracks if available
final topTracksList = artistData['top_tracks'] as List<dynamic>? ?? [];
final topTracks = topTracksList.map((t) => _parseSearchTrack(t as Map<String, dynamic>, source: extensionId)).toList();
@@ -209,25 +202,16 @@ class TrackNotifier extends Notifier<TrackState> {
}
}
// No extension handler found, try Spotify URL parsing
final parsed = await PlatformBridge.parseSpotifyUrl(url);
if (!_isRequestValid(requestId)) return; // Request cancelled
final type = parsed['type'] as String;
// Use the new fallback-enabled method
Map<String, dynamic> metadata;
try {
// ignore: avoid_print
print('[FetchURL] Fetching $type with Deezer fallback enabled...');
metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
// ignore: avoid_print
print('[FetchURL] Metadata fetch success');
} catch (e) {
// If fallback also fails, show error
// ignore: avoid_print
print('[FetchURL] Metadata fetch failed: $e');
rethrow;
}
@@ -252,7 +236,6 @@ class TrackNotifier extends Notifier<TrackState> {
albumName: albumInfo['name'] as String?,
coverUrl: albumInfo['images'] as String?,
);
// Pre-warm cache for album tracks in background
_preWarmCacheForTracks(tracks);
} else if (type == 'playlist') {
final playlistInfo = metadata['playlist_info'] as Map<String, dynamic>;
@@ -265,14 +248,13 @@ class TrackNotifier extends Notifier<TrackState> {
playlistName: owner?['name'] as String?,
coverUrl: owner?['images'] as String?,
);
// Pre-warm cache for playlist tracks in background
_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: [], // No tracks for artist view
tracks: [],
isLoading: false,
artistId: artistInfo['id'] as String?,
artistName: artistInfo['name'] as String?,
@@ -281,21 +263,17 @@ class TrackNotifier extends Notifier<TrackState> {
);
}
} catch (e) {
if (!_isRequestValid(requestId)) return; // Request cancelled
// Preserve hasSearchText on error so user stays on search screen
if (!_isRequestValid(requestId)) return;
state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText);
}
}
Future<void> search(String query, {String? metadataSource}) async {
// Increment request ID to cancel any pending requests
final requestId = ++_currentRequestId;
// Preserve hasSearchText during search
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
try {
// Check if extension providers should be used for search
final settings = ref.read(settingsProvider);
final extensionState = ref.read(extensionProvider);
final hasActiveMetadataExtensions = extensionState.extensions.any(
@@ -308,7 +286,6 @@ class TrackNotifier extends Notifier<TrackState> {
searchProvider != null &&
searchProvider.isNotEmpty;
// Use Deezer or Spotify based on settings
final source = metadataSource ?? 'deezer';
_log.i(
@@ -318,14 +295,12 @@ class TrackNotifier extends Notifier<TrackState> {
Map<String, dynamic> results;
List<Track> extensionTracks = [];
// Try extension providers first if enabled
if (useExtensions) {
try {
_log.d('Calling extension search API...');
final extResults = await PlatformBridge.searchTracksWithExtensions(query, limit: 20);
_log.i('Extensions returned ${extResults.length} tracks');
// Parse extension results
for (final t in extResults) {
try {
extensionTracks.add(_parseSearchTrack(t));
@@ -338,7 +313,6 @@ class TrackNotifier extends Notifier<TrackState> {
}
}
// Also search with built-in providers
if (source == 'deezer') {
_log.d('Calling Deezer search API...');
results = await PlatformBridge.searchDeezerAll(query, trackLimit: 20, artistLimit: 5);
@@ -359,13 +333,10 @@ class TrackNotifier extends Notifier<TrackState> {
_log.d('Raw results: ${trackList.length} tracks, ${artistList.length} artists');
// Parse tracks with error handling per item
final tracks = <Track>[];
// Add extension tracks first (they have priority)
tracks.addAll(extensionTracks);
// Add built-in provider tracks, avoiding duplicates by ISRC
final existingIsrcs = extensionTracks
.where((t) => t.isrc != null && t.isrc!.isNotEmpty)
.map((t) => t.isrc!)
@@ -376,7 +347,6 @@ class TrackNotifier extends Notifier<TrackState> {
try {
if (t is Map<String, dynamic>) {
final track = _parseSearchTrack(t);
// Skip if we already have this track from extensions
if (track.isrc != null && existingIsrcs.contains(track.isrc)) {
continue;
}
@@ -389,7 +359,6 @@ class TrackNotifier extends Notifier<TrackState> {
}
}
// Parse artists with error handling per item
final artists = <SearchArtist>[];
for (int i = 0; i < artistList.length; i++) {
final a = artistList[i];
@@ -419,12 +388,9 @@ class TrackNotifier extends Notifier<TrackState> {
}
}
/// Perform custom search using a specific extension
Future<void> customSearch(String extensionId, String query, {Map<String, dynamic>? options}) async {
// Increment request ID to cancel any pending requests
final requestId = ++_currentRequestId;
// Preserve hasSearchText during search
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
try {
@@ -439,7 +405,6 @@ class TrackNotifier extends Notifier<TrackState> {
_log.i('Custom search returned ${results.length} tracks');
// Parse tracks with error handling per item, setting source to extension ID
final tracks = <Track>[];
for (int i = 0; i < results.length; i++) {
final t = results[i];
@@ -454,7 +419,7 @@ class TrackNotifier extends Notifier<TrackState> {
state = TrackState(
tracks: tracks,
searchArtists: [], // Custom search doesn't return artists
searchArtists: [],
isLoading: false,
hasSearchText: state.hasSearchText,
searchExtensionId: extensionId, // Store which extension was used
@@ -502,7 +467,6 @@ class TrackNotifier extends Notifier<TrackState> {
tracks[index] = updatedTrack;
state = state.copyWith(tracks: tracks);
} catch (e) {
// Silently fail availability check
}
}
@@ -512,10 +476,12 @@ class TrackNotifier extends Notifier<TrackState> {
/// Set search text state for back button handling
void setSearchText(bool hasText) {
if (state.hasSearchText == hasText) {
return;
}
state = state.copyWith(hasSearchText: hasText);
}
/// Set recent access mode state
void setShowingRecentAccess(bool showing) {
state = state.copyWith(isShowingRecentAccess: showing);
}
@@ -554,7 +520,6 @@ class TrackNotifier extends Notifier<TrackState> {
}
Track _parseSearchTrack(Map<String, dynamic> data, {String? source}) {
// Handle duration_ms which might be int or double
int durationMs = 0;
final durationValue = data['duration_ms'];
if (durationValue is int) {
@@ -563,7 +528,6 @@ class TrackNotifier extends Notifier<TrackState> {
durationMs = durationValue.toInt();
}
// Get item_type - can be 'track', 'album', or 'playlist'
final itemType = data['item_type']?.toString();
return Track(
@@ -607,26 +571,19 @@ class TrackNotifier extends Notifier<TrackState> {
);
}
/// Pre-warm track ID cache for faster downloads
/// Runs in background, doesn't block UI
void _preWarmCacheForTracks(List<Track> tracks) {
// Only pre-warm if we have tracks with ISRC
final tracksWithIsrc = tracks.where((t) => t.isrc != null && t.isrc!.isNotEmpty).toList();
if (tracksWithIsrc.isEmpty) return;
// Build request list for Go backend
final cacheRequests = tracksWithIsrc.map((t) => {
'isrc': t.isrc!,
'track_name': t.name,
'artist_name': t.artistName,
'spotify_id': t.id, // Include Spotify ID for Amazon lookup
'service': 'tidal', // Default to tidal for pre-warming
'service': 'tidal',
}).toList();
// Fire and forget - runs in background
PlatformBridge.preWarmTrackCache(cacheRequests).catchError((_) {
// Silently ignore errors - this is just an optimization
});
PlatformBridge.preWarmTrackCache(cacheRequests).catchError((_) {});
}
}
+268 -100
View File
@@ -2,6 +2,8 @@ import 'dart:io';
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/services/palette_service.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/models/download_item.dart';
@@ -10,8 +12,9 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/recent_access_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart';
import 'package:spotiflac_android/screens/artist_screen.dart';
import 'package:spotiflac_android/screens/home_tab.dart' show ExtensionArtistScreen;
/// Simple in-memory cache for album tracks
class _AlbumCache {
static final Map<String, _CacheEntry> _cache = {};
static const Duration _ttl = Duration(minutes: 10);
@@ -37,12 +40,14 @@ class _CacheEntry {
_CacheEntry(this.tracks, this.expiresAt);
}
/// Album detail screen with Material Expressive 3 design
class AlbumScreen extends ConsumerStatefulWidget {
final String albumId;
final String albumName;
final String? coverUrl;
final List<Track>? tracks; // Optional - will fetch if null
final String? extensionId; // If from extension
final String? artistId; // Artist ID for navigation
final String? artistName; // Artist name for navigation
const AlbumScreen({
super.key,
@@ -50,6 +55,9 @@ class AlbumScreen extends ConsumerStatefulWidget {
required this.albumName,
this.coverUrl,
this.tracks,
this.extensionId,
this.artistId,
this.artistName,
});
@override
@@ -60,12 +68,17 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
List<Track>? _tracks;
bool _isLoading = false;
String? _error;
Color? _dominantColor;
bool _showTitleInAppBar = false;
String? _artistId;
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
// Record access for recent history
_scrollController.addListener(_onScroll);
WidgetsBinding.instance.addPostFrameCallback((_) {
final providerId = widget.albumId.startsWith('deezer:') ? 'deezer' : 'spotify';
ref.read(recentAccessProvider.notifier).recordAlbumAccess(
@@ -74,31 +87,68 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
artistName: widget.tracks?.firstOrNull?.artistName,
imageUrl: widget.coverUrl,
providerId: providerId,
);
);
});
// Priority: widget.tracks > cache > fetch
_tracks = widget.tracks ?? _AlbumCache.get(widget.albumId);
_artistId = widget.artistId; // Use provided artist ID if available
if (_tracks == null) {
_fetchTracks();
}
_extractDominantColor();
}
Future<void> _fetchTracks() async {
@override
void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
final shouldShow = _scrollController.offset > 280;
if (shouldShow != _showTitleInAppBar) {
setState(() => _showTitleInAppBar = shouldShow);
}
}
Future<void> _extractDominantColor() async {
if (widget.coverUrl == null) return;
final color = await PaletteService.instance.extractDominantColor(widget.coverUrl);
if (mounted && color != null) {
setState(() => _dominantColor = color);
}
}
String _formatReleaseDate(String date) {
// Handle formats: "2024-01-15", "2024-01", "2024"
if (date.length >= 10) {
// Full date: 2024-01-15
final parts = date.substring(0, 10).split('-');
if (parts.length == 3) {
return '${parts[2]}/${parts[1]}/${parts[0]}'; // DD/MM/YYYY
}
} else if (date.length >= 7) {
// Month: 2024-01
final parts = date.split('-');
if (parts.length >= 2) {
return '${parts[1]}/${parts[0]}'; // MM/YYYY
}
}
return date; // Year only or unknown format
}
Future<void> _fetchTracks() async {
setState(() => _isLoading = true);
try {
Map<String, dynamic> metadata;
// Check if this is a Deezer album ID (format: "deezer:123456")
if (widget.albumId.startsWith('deezer:')) {
final deezerAlbumId = widget.albumId.replaceFirst('deezer:', '');
// ignore: avoid_print
print('[AlbumScreen] Fetching from Deezer: $deezerAlbumId');
metadata = await PlatformBridge.getDeezerMetadata('album', deezerAlbumId);
} else {
// Spotify album - use fallback method
// ignore: avoid_print
print('[AlbumScreen] Fetching from Spotify with fallback: ${widget.albumId}');
final url = 'https://open.spotify.com/album/${widget.albumId}';
metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
}
@@ -106,12 +156,16 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
// Store in cache
// Extract artist ID from album_info if available
final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
final artistId = albumInfo?['artist_id'] as String?;
_AlbumCache.set(widget.albumId, tracks);
if (mounted) {
setState(() {
_tracks = tracks;
_artistId = artistId;
_isLoading = false;
});
}
@@ -148,6 +202,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
return Scaffold(
body: CustomScrollView(
controller: _scrollController,
slivers: [
_buildAppBar(context, colorScheme),
_buildInfoCard(context, colorScheme),
@@ -172,74 +227,106 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
}
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
final screenWidth = MediaQuery.of(context).size.width;
final coverSize = screenWidth * 0.5; // 50% of screen width
final bgColor = _dominantColor ?? colorScheme.surface;
return SliverAppBar(
expandedHeight: 280,
expandedHeight: 320,
pinned: true,
stretch: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
flexibleSpace: FlexibleSpaceBar(
background: Stack(
fit: StackFit.expand,
children: [
if (widget.coverUrl != null)
CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
color: Colors.black.withValues(alpha: 0.5),
colorBlendMode: BlendMode.darken,
memCacheWidth: 600,
),
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
colorScheme.surface.withValues(alpha: 0.8),
colorScheme.surface,
],
stops: const [0.0, 0.7, 1.0],
),
),
),
Center(
child: Padding(
padding: const EdgeInsets.only(top: 60),
child: Container(
width: 140,
height: 140,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: widget.coverUrl != null
? CachedNetworkImage(imageUrl: widget.coverUrl!, fit: BoxFit.cover, memCacheWidth: 280)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.album, size: 48, color: colorScheme.onSurfaceVariant),
),
),
),
),
),
],
title: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: _showTitleInAppBar ? 1.0 : 0.0,
child: Text(
widget.albumName,
style: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.w600,
fontSize: 16,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final collapseRatio = (constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight);
final showContent = collapseRatio > 0.3;
return FlexibleSpaceBar(
collapseMode: CollapseMode.none,
background: Stack(
fit: StackFit.expand,
children: [
// Background with dominant color
AnimatedContainer(
duration: const Duration(milliseconds: 500),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
bgColor,
bgColor.withValues(alpha: 0.8),
colorScheme.surface,
],
stops: const [0.0, 0.6, 1.0],
),
),
),
AnimatedOpacity(
duration: const Duration(milliseconds: 150),
opacity: showContent ? 1.0 : 0.0,
child: Center(
child: Padding(
padding: const EdgeInsets.only(top: 60),
child: Container(
width: coverSize,
height: coverSize,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.4),
blurRadius: 30,
offset: const Offset(0, 15),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: widget.coverUrl != null
? CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
memCacheWidth: (coverSize * 2).toInt(),
cacheManager: CoverCacheManager.instance,
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.album, size: 64, color: colorScheme.onSurfaceVariant),
),
),
),
),
),
),
],
),
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
);
},
),
leading: IconButton(
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(color: colorScheme.surface.withValues(alpha: 0.8), shape: BoxShape.circle),
decoration: BoxDecoration(
color: colorScheme.surface.withValues(alpha: 0.8),
shape: BoxShape.circle,
),
child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
),
onPressed: () => Navigator.pop(context),
@@ -247,8 +334,11 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
);
}
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
final tracks = _tracks ?? [];
final artistName = tracks.isNotEmpty ? tracks.first.artistName : null;
final releaseDate = tracks.isNotEmpty ? tracks.first.releaseDate : null;
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
@@ -265,27 +355,61 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
widget.albumName,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface),
),
const SizedBox(height: 8),
if (tracks.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(color: colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(20)),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.music_note, size: 14, color: colorScheme.onSecondaryContainer),
const SizedBox(width: 4),
Text(context.l10n.tracksCount(tracks.length), style: TextStyle(color: colorScheme.onSecondaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
],
if (artistName != null && artistName.isNotEmpty) ...[
const SizedBox(height: 4),
GestureDetector(
onTap: () => _navigateToArtist(context, artistName),
child: Text(
artistName,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: colorScheme.primary,
),
),
),
],
const SizedBox(height: 12),
if (tracks.isNotEmpty)
Wrap(
spacing: 8,
runSpacing: 8,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(color: colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(20)),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.music_note, size: 14, color: colorScheme.onSecondaryContainer),
const SizedBox(width: 4),
Text(context.l10n.tracksCount(tracks.length), style: TextStyle(color: colorScheme.onSecondaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
],
),
),
if (releaseDate != null && releaseDate.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(color: colorScheme.tertiaryContainer, borderRadius: BorderRadius.circular(20)),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.calendar_today, size: 14, color: colorScheme.onTertiaryContainer),
const SizedBox(width: 4),
Text(_formatReleaseDate(releaseDate), style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
],
),
),
],
),
if (tracks.isNotEmpty) ...[
const SizedBox(height: 16),
FilledButton.icon(
onPressed: () => _downloadAll(context),
icon: const Icon(Icons.download),
icon: const Icon(Icons.download, size: 18),
label: Text(context.l10n.downloadAllCount(tracks.length)),
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(52), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
),
),
],
],
@@ -364,11 +488,51 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
);
} else {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length))));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length))));
}
}
/// Build error widget with special handling for rate limit (429)
void _navigateToArtist(BuildContext context, String artistName) {
// Use stored artist ID if available, otherwise use a placeholder
final artistId = _artistId ??
(widget.albumId.startsWith('deezer:') ? 'deezer:unknown' : 'unknown');
// Don't navigate if artist ID is unknown
if (artistId == 'unknown' || artistId == 'deezer:unknown' || artistId.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Artist information not available')),
);
return;
}
// If from extension, use ExtensionArtistScreen
if (widget.extensionId != null) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ExtensionArtistScreen(
extensionId: widget.extensionId!,
artistId: artistId,
artistName: artistName,
coverUrl: widget.coverUrl,
),
),
);
return;
}
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ArtistScreen(
artistId: artistId,
artistName: artistName,
coverUrl: widget.coverUrl,
),
),
);
}
Widget _buildErrorWidget(String error, ColorScheme colorScheme) {
final isRateLimit = error.contains('429') ||
error.toLowerCase().contains('rate limit') ||
@@ -413,7 +577,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
);
}
// Default error display
return Card(
elevation: 0,
color: colorScheme.errorContainer.withValues(alpha: 0.5),
@@ -432,7 +595,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
}
}
/// Separate Consumer widget for each track - only rebuilds when this specific track's status changes
class _AlbumTrackItem extends ConsumerWidget {
final Track track;
final VoidCallback onDownload;
@@ -443,12 +605,10 @@ class _AlbumTrackItem extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
// Only watch the specific item for this track
final queueItem = ref.watch(downloadQueueProvider.select((state) {
return state.items.where((item) => item.track.id == track.id).firstOrNull;
}));
final queueItem = ref.watch(
downloadQueueLookupProvider.select((lookup) => lookup.byTrackId[track.id]),
);
// Check if track is in history (already downloaded before)
final isInHistory = ref.watch(downloadHistoryProvider.select((state) {
return state.isDownloaded(track.id);
}));
@@ -459,7 +619,6 @@ class _AlbumTrackItem extends ConsumerWidget {
final isCompleted = queueItem?.status == DownloadStatus.completed;
final progress = queueItem?.progress ?? 0.0;
// Show as downloaded if in queue completed OR in history
final showAsDownloaded = isCompleted || (!isQueued && isInHistory);
return Padding(
@@ -468,11 +627,20 @@ class _AlbumTrackItem extends ConsumerWidget {
elevation: 0,
color: Colors.transparent,
margin: const EdgeInsets.symmetric(vertical: 2),
child: ListTile(
child: ListTile(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
leading: track.coverUrl != null
? ClipRRect(borderRadius: BorderRadius.circular(8), child: CachedNetworkImage(imageUrl: track.coverUrl!, width: 48, height: 48, fit: BoxFit.cover, memCacheWidth: 96))
: Container(width: 48, height: 48, decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)), child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)),
leading: SizedBox(
width: 32,
child: Center(
child: Text(
'${track.trackNumber ?? 0}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
),
),
),
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500)),
subtitle: Text(track.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant)),
trailing: _buildDownloadButton(context, ref, colorScheme, isQueued: isQueued, isDownloading: isDownloading, isFinalizing: isFinalizing, showAsDownloaded: showAsDownloaded, isInHistory: isInHistory, progress: progress),
File diff suppressed because it is too large Load Diff
+257 -78
View File
@@ -4,6 +4,8 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:open_filex/open_filex.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/services/palette_service.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/utils/mime_utils.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
@@ -27,18 +29,73 @@ class DownloadedAlbumScreen extends ConsumerStatefulWidget {
}
class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
// Multi-select state
bool _isSelectionMode = false;
final Set<String> _selectedIds = {};
Color? _dominantColor;
bool _showTitleInAppBar = false;
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_extractDominantColor();
}
@override
void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
final shouldShow = _scrollController.offset > 280;
if (shouldShow != _showTitleInAppBar) {
setState(() => _showTitleInAppBar = shouldShow);
}
}
Future<void> _extractDominantColor() async {
if (widget.coverUrl == null || widget.coverUrl!.isEmpty) return;
// Check cache first (instant)
final cached = PaletteService.instance.getCached(widget.coverUrl);
if (cached != null) {
if (mounted && cached != _dominantColor) {
setState(() {
_dominantColor = cached;
});
}
return;
}
// Extract in isolate (non-blocking)
final color = await PaletteService.instance.extractDominantColor(widget.coverUrl);
if (mounted && color != null && color != _dominantColor) {
setState(() {
_dominantColor = color;
});
}
}
/// Get tracks for this album from history provider (reactive)
List<DownloadHistoryItem> _getAlbumTracks(List<DownloadHistoryItem> allItems) {
return allItems.where((item) {
final itemKey = '${item.albumName}|${item.albumArtist ?? item.artistName}';
final albumKey = '${widget.albumName}|${widget.artistName}';
// Use albumArtist if available and not empty, otherwise artistName
final itemArtist = (item.albumArtist != null && item.albumArtist!.isNotEmpty)
? item.albumArtist!
: item.artistName;
// Use lowercase for case-insensitive matching
final itemKey = '${item.albumName.toLowerCase()}|${itemArtist.toLowerCase()}';
final albumKey = '${widget.albumName.toLowerCase()}|${widget.artistName.toLowerCase()}';
return itemKey == albumKey;
}).toList()
..sort((a, b) {
// Sort by disc number first, then by track number
final aDisc = a.discNumber ?? 1;
final bDisc = b.discNumber ?? 1;
if (aDisc != bDisc) return aDisc.compareTo(bDisc);
final aNum = a.trackNumber ?? 999;
final bNum = b.trackNumber ?? 999;
if (aNum != bNum) return aNum.compareTo(bNum);
@@ -46,6 +103,17 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
});
}
Map<int, List<DownloadHistoryItem>> _groupTracksByDisc(
List<DownloadHistoryItem> tracks,
) {
final discMap = <int, List<DownloadHistoryItem>>{};
for (final track in tracks) {
final discNumber = track.discNumber ?? 1;
discMap.putIfAbsent(discNumber, () => []).add(track);
}
return discMap;
}
void _enterSelectionMode(String itemId) {
HapticFeedback.mediumImpact();
setState(() {
@@ -146,6 +214,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
}
void _navigateToMetadataScreen(DownloadHistoryItem item) {
_precacheCover(item.coverUrl);
Navigator.push(context, PageRouteBuilder(
transitionDuration: const Duration(milliseconds: 300),
reverseTransitionDuration: const Duration(milliseconds: 250),
@@ -154,24 +223,37 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
));
}
void _precacheCover(String? url) {
if (url == null || url.isEmpty) return;
if (!url.startsWith('http://') && !url.startsWith('https://')) {
return;
}
precacheImage(
CachedNetworkImageProvider(url, cacheManager: CoverCacheManager.instance),
context,
);
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final bottomPadding = MediaQuery.of(context).padding.bottom;
// Watch history and get tracks for this album (reactive!)
final allHistoryItems = ref.watch(downloadHistoryProvider.select((s) => s.items));
final tracks = _getAlbumTracks(allHistoryItems);
// Auto-pop if album has less than 2 tracks (no longer an "album")
if (tracks.length < 2) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) Navigator.pop(context);
});
return const SizedBox.shrink();
// Show empty state if no tracks found
if (tracks.isEmpty) {
return Scaffold(
appBar: AppBar(
title: Text(widget.albumName),
),
body: Center(
child: Text('No tracks found for this album'),
),
);
}
// Clean up selected IDs that no longer exist
final validIds = tracks.map((t) => t.id).toSet();
_selectedIds.removeWhere((id) => !validIds.contains(id));
if (_selectedIds.isEmpty && _isSelectionMode) {
@@ -191,6 +273,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
body: Stack(
children: [
CustomScrollView(
controller: _scrollController,
slivers: [
_buildAppBar(context, colorScheme),
_buildInfoCard(context, colorScheme, tracks),
@@ -200,7 +283,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
],
),
// Bottom Selection Action Bar
AnimatedPositioned(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
@@ -216,69 +298,99 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
}
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
final screenWidth = MediaQuery.of(context).size.width;
final coverSize = screenWidth * 0.5; // 50% of screen width
final bgColor = _dominantColor ?? colorScheme.surface;
return SliverAppBar(
expandedHeight: 280,
expandedHeight: 320,
pinned: true,
stretch: true,
backgroundColor: colorScheme.surface,
backgroundColor: colorScheme.surface, // Use theme color for collapsed state
surfaceTintColor: Colors.transparent,
flexibleSpace: FlexibleSpaceBar(
background: Stack(
fit: StackFit.expand,
children: [
if (widget.coverUrl != null)
CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
color: Colors.black.withValues(alpha: 0.5),
colorBlendMode: BlendMode.darken,
memCacheWidth: 600,
),
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
colorScheme.surface.withValues(alpha: 0.8),
colorScheme.surface,
],
stops: const [0.0, 0.7, 1.0],
),
),
),
Center(
child: Padding(
padding: const EdgeInsets.only(top: 60),
child: Container(
width: 140,
height: 140,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: widget.coverUrl != null
? CachedNetworkImage(imageUrl: widget.coverUrl!, fit: BoxFit.cover, memCacheWidth: 280)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.album, size: 48, color: colorScheme.onSurfaceVariant),
),
),
),
),
),
],
title: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: _showTitleInAppBar ? 1.0 : 0.0,
child: Text(
widget.albumName,
style: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.w600,
fontSize: 16,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final collapseRatio = (constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight);
final showContent = collapseRatio > 0.3;
return FlexibleSpaceBar(
collapseMode: CollapseMode.none,
background: Stack(
fit: StackFit.expand,
children: [
// Background with dominant color
AnimatedContainer(
duration: const Duration(milliseconds: 500),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
bgColor,
bgColor.withValues(alpha: 0.8),
colorScheme.surface,
],
stops: const [0.0, 0.6, 1.0],
),
),
),
// Cover image centered - fade out when collapsing
AnimatedOpacity(
duration: const Duration(milliseconds: 150),
opacity: showContent ? 1.0 : 0.0,
child: Center(
child: Padding(
padding: const EdgeInsets.only(top: 60),
child: Container(
width: coverSize,
height: coverSize,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.4),
blurRadius: 30,
offset: const Offset(0, 15),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: widget.coverUrl != null
? CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
memCacheWidth: (coverSize * 2).toInt(),
cacheManager: CoverCacheManager.instance,
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.album, size: 64, color: colorScheme.onSurfaceVariant),
),
),
),
),
),
),
],
),
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
);
},
),
leading: IconButton(
icon: Container(
@@ -393,16 +505,83 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
}
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks) {
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final track = tracks[index];
return KeyedSubtree(
final discMap = _groupTracksByDisc(tracks);
if (discMap.length <= 1) {
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final track = tracks[index];
return KeyedSubtree(
key: ValueKey(track.id),
child: _buildTrackItem(context, colorScheme, track),
);
},
childCount: tracks.length,
),
);
}
final discNumbers = discMap.keys.toList()..sort();
final List<Widget> children = [];
for (final discNumber in discNumbers) {
final discTracks = discMap[discNumber];
if (discTracks == null || discTracks.isEmpty) continue;
// Add disc separator
children.add(_buildDiscSeparator(context, colorScheme, discNumber));
// Add tracks for this disc
for (final track in discTracks) {
children.add(
KeyedSubtree(
key: ValueKey(track.id),
child: _buildTrackItem(context, colorScheme, track),
);
},
childCount: tracks.length,
),
);
}
}
return SliverList(
delegate: SliverChildListDelegate(children),
);
}
Widget _buildDiscSeparator(BuildContext context, ColorScheme colorScheme, int discNumber) {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
child: Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(16),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.album, size: 16, color: colorScheme.onSecondaryContainer),
const SizedBox(width: 6),
Text(
context.l10n.downloadedAlbumDiscHeader(discNumber),
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: colorScheme.onSecondaryContainer,
fontWeight: FontWeight.w600,
),
),
],
),
),
const SizedBox(width: 12),
Expanded(
child: Container(
height: 1,
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
),
),
],
),
);
}
+29 -36
View File
@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/providers/track_provider.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
@@ -45,16 +46,15 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
}
}
void _downloadTrack(int index) {
final trackState = ref.read(trackProvider);
if (index >= 0 && index < trackState.tracks.length) {
final track = trackState.tracks[index];
final settings = ref.read(settingsProvider);
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Added "${track.name}" to queue')),
);
}
void _downloadTrack(Track track) {
final settings = ref.read(settingsProvider);
ref.read(downloadQueueProvider.notifier).addToQueue(
track,
settings.defaultService,
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Added "${track.name}" to queue')),
);
}
void _downloadAll() {
@@ -75,7 +75,6 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
setState(() => _currentIndex = index);
switch (index) {
case 0:
// Already on home
break;
case 1:
context.push('/queue');
@@ -89,8 +88,10 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
@override
Widget build(BuildContext context) {
final trackState = ref.watch(trackProvider);
final queueState = ref.watch(downloadQueueProvider);
final queuedCount =
ref.watch(downloadQueueProvider.select((s) => s.queuedCount));
final colorScheme = Theme.of(context).colorScheme;
final tracks = trackState.tracks;
return Scaffold(
appBar: AppBar(
@@ -112,7 +113,6 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// URL Input
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: TextField(
@@ -132,7 +132,6 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
),
),
// Error message
if (trackState.error != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
@@ -142,35 +141,32 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
),
),
// Loading indicator
if (trackState.isLoading)
LinearProgressIndicator(color: colorScheme.primary),
// Album/Playlist header
if (trackState.albumName != null || trackState.playlistName != null)
_buildHeader(trackState, colorScheme),
// Download All button
if (trackState.tracks.length > 1)
if (tracks.length > 1)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: FilledButton.icon(
onPressed: _downloadAll,
icon: const Icon(Icons.download),
label: Text('Download All (${trackState.tracks.length})'),
label: Text('Download All (${tracks.length})'),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
),
),
),
// Track list
Expanded(
child: trackState.tracks.isEmpty
child: tracks.isEmpty
? _buildEmptyState(colorScheme)
: ListView.builder(
itemCount: trackState.tracks.length,
itemBuilder: (context, index) => _buildTrackTile(index, colorScheme),
itemCount: tracks.length,
itemBuilder: (context, index) =>
_buildTrackTile(tracks[index], colorScheme),
),
),
],
@@ -186,13 +182,13 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
),
NavigationDestination(
icon: Badge(
isLabelVisible: queueState.queuedCount > 0,
label: Text('${queueState.queuedCount}'),
isLabelVisible: queuedCount > 0,
label: Text('$queuedCount'),
child: const Icon(Icons.queue_music_outlined),
),
selectedIcon: Badge(
isLabelVisible: queueState.queuedCount > 0,
label: Text('${queueState.queuedCount}'),
isLabelVisible: queuedCount > 0,
label: Text('$queuedCount'),
child: const Icon(Icons.queue_music),
),
label: 'Queue',
@@ -217,11 +213,12 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
if (state.coverUrl != null)
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
child: CachedNetworkImage(
imageUrl: state.coverUrl!,
width: 80,
height: 80,
fit: BoxFit.cover,
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) => Container(
width: 80,
height: 80,
@@ -252,7 +249,6 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
],
),
),
// Play all button
FilledButton.tonal(
onPressed: _downloadAll,
style: FilledButton.styleFrom(
@@ -267,11 +263,9 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
);
}
Widget _buildTrackTile(int index, ColorScheme colorScheme) {
final track = ref.watch(trackProvider).tracks[index];
Widget _buildTrackTile(Track track, ColorScheme colorScheme) {
final isCollection = track.isCollection;
// Determine subtitle text based on item type
String subtitleText;
if (isCollection) {
final typeLabel = track.albumType ?? (track.isPlaylistItem ? 'Playlist' : 'Album');
@@ -290,11 +284,12 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
leading: track.coverUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
child: CachedNetworkImage(
imageUrl: track.coverUrl!,
width: 48,
height: 48,
fit: BoxFit.cover,
cacheManager: CoverCacheManager.instance,
),
)
: Container(
@@ -324,16 +319,14 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
color: colorScheme.onSurfaceVariant,
),
),
onTap: () => isCollection ? _openCollection(track) : _downloadTrack(index),
onTap: () => isCollection ? _openCollection(track) : _downloadTrack(track),
);
}
Future<void> _openCollection(Track track) async {
// Get the extension ID from the track source
final extensionId = track.source;
if (extensionId == null) return;
// Fetch album/playlist tracks using the extension
try {
if (track.isAlbumItem) {
final albumData = await PlatformBridge.getAlbumWithExtension(extensionId, track.id);
+1130 -218
View File
File diff suppressed because it is too large Load Diff
+1 -28
View File
@@ -30,13 +30,12 @@ class _MainShellState extends ConsumerState<MainShell> {
late PageController _pageController;
bool _hasCheckedUpdate = false;
StreamSubscription<String>? _shareSubscription;
DateTime? _lastBackPress; // For double-tap to exit
DateTime? _lastBackPress;
@override
void initState() {
super.initState();
_pageController = PageController(initialPage: _currentIndex);
// Check for updates after first frame
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkForUpdates();
_setupShareListener();
@@ -44,14 +43,12 @@ class _MainShellState extends ConsumerState<MainShell> {
}
void _setupShareListener() {
// Check for pending URL that was received before listener was ready
final pendingUrl = ShareIntentService().consumePendingUrl();
if (pendingUrl != null) {
_log.d('Processing pending shared URL: $pendingUrl');
_handleSharedUrl(pendingUrl);
}
// Listen for future shared URLs with error handling
_shareSubscription = ShareIntentService().sharedUrlStream.listen(
(url) {
_log.d('Received shared URL from stream: $url');
@@ -65,18 +62,13 @@ class _MainShellState extends ConsumerState<MainShell> {
}
void _handleSharedUrl(String url) {
// Pop any existing screens (Album, Artist, Settings sub-pages) to return to root
Navigator.of(context).popUntil((route) => route.isFirst);
// Navigate to Home tab
if (_currentIndex != 0) {
_onNavTap(0);
}
// Fetch metadata for shared URL
ref.read(trackProvider.notifier).fetchFromUrl(url);
// Mark that user has searched (hide helper text)
ref.read(settingsProvider.notifier).setHasSearchedBefore();
// Show snackbar
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.loadingSharedLink)),
@@ -124,49 +116,39 @@ class _MainShellState extends ConsumerState<MainShell> {
void _onPageChanged(int index) {
if (_currentIndex != index) {
setState(() => _currentIndex = index);
// Unfocus any text field when switching tabs to prevent keyboard from appearing
// Use primaryFocus for more aggressive unfocus that works with keep-alive widgets
FocusManager.instance.primaryFocus?.unfocus();
}
}
/// Handle back press with double-tap to exit
void _handleBackPress() {
final trackState = ref.read(trackProvider);
// Check if keyboard is visible - if so, just dismiss keyboard, don't clear search
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
if (isKeyboardVisible) {
FocusManager.instance.primaryFocus?.unfocus();
return;
}
// If on Home tab and showing recent access mode, exit it
if (_currentIndex == 0 && trackState.isShowingRecentAccess) {
ref.read(trackProvider.notifier).setShowingRecentAccess(false);
// Also unfocus search bar when exiting recent access mode
FocusManager.instance.primaryFocus?.unfocus();
return;
}
// If on Home tab and has text in search bar or has content (but not loading), clear it
if (_currentIndex == 0 && !trackState.isLoading && (trackState.hasSearchText || trackState.hasContent)) {
ref.read(trackProvider.notifier).clear();
return;
}
// If not on Home tab, go to Home tab first
if (_currentIndex != 0) {
_onNavTap(0);
return;
}
// If loading, ignore back press
if (trackState.isLoading) {
return;
}
// Double-tap to exit
final now = DateTime.now();
if (_lastBackPress != null && now.difference(_lastBackPress!) < const Duration(seconds: 2)) {
SystemNavigator.pop();
@@ -189,12 +171,8 @@ class _MainShellState extends ConsumerState<MainShell> {
final showStore = ref.watch(settingsProvider.select((s) => s.showExtensionStore));
final storeUpdatesCount = ref.watch(storeProvider.select((s) => s.updatesAvailableCount));
// Check if keyboard is visible (bottom inset > 0 means keyboard is showing)
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
// Determine if we can pop (for predictive back animation)
// canPop is true when we're at root with no content - enables predictive back gesture
// IMPORTANT: Never allow pop when keyboard is visible to prevent accidental navigation
final canPop = _currentIndex == 0 &&
!trackState.hasSearchText &&
!trackState.hasContent &&
@@ -202,7 +180,6 @@ class _MainShellState extends ConsumerState<MainShell> {
!trackState.isShowingRecentAccess &&
!isKeyboardVisible;
// Build tabs and destinations based on settings
final tabs = <Widget>[
const HomeTab(),
QueueTab(
@@ -255,7 +232,6 @@ class _MainShellState extends ConsumerState<MainShell> {
),
];
// Clamp current index if tabs changed
final maxIndex = tabs.length - 1;
if (_currentIndex > maxIndex) {
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -270,12 +246,9 @@ class _MainShellState extends ConsumerState<MainShell> {
canPop: canPop,
onPopInvokedWithResult: (didPop, result) async {
if (didPop) {
// System handled the pop - this means predictive back completed
// We need to handle double-tap to exit here
return;
}
// Handle back press manually when canPop is false
_handleBackPress();
},
child: Scaffold(
+170 -74
View File
@@ -2,6 +2,8 @@ import 'dart:io';
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/services/palette_service.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/models/download_item.dart';
@@ -9,8 +11,7 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart';
/// Playlist detail screen with Material Expressive 3 design
class PlaylistScreen extends ConsumerWidget {
class PlaylistScreen extends ConsumerStatefulWidget {
final String playlistName;
final String? coverUrl;
final List<Track> tracks;
@@ -23,16 +24,55 @@ class PlaylistScreen extends ConsumerWidget {
});
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<PlaylistScreen> createState() => _PlaylistScreenState();
}
class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
Color? _dominantColor;
bool _showTitleInAppBar = false;
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_extractDominantColor();
}
@override
void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
final shouldShow = _scrollController.offset > 280;
if (shouldShow != _showTitleInAppBar) {
setState(() => _showTitleInAppBar = shouldShow);
}
}
Future<void> _extractDominantColor() async {
if (widget.coverUrl == null) return;
final color = await PaletteService.instance.extractDominantColor(widget.coverUrl);
if (mounted && color != null) {
setState(() => _dominantColor = color);
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
body: CustomScrollView(
controller: _scrollController,
slivers: [
_buildAppBar(context, colorScheme),
_buildInfoCard(context, ref, colorScheme),
_buildInfoCard(context, colorScheme),
_buildTrackListHeader(context, colorScheme),
_buildTrackList(context, ref, colorScheme),
_buildTrackList(context, colorScheme),
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
),
@@ -40,59 +80,115 @@ class PlaylistScreen extends ConsumerWidget {
}
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
final screenWidth = MediaQuery.of(context).size.width;
final coverSize = screenWidth * 0.5; // 50% of screen width
final bgColor = _dominantColor ?? colorScheme.surface;
return SliverAppBar(
expandedHeight: 280,
expandedHeight: 320,
pinned: true,
stretch: true,
backgroundColor: colorScheme.surface,
backgroundColor: colorScheme.surface, // Use theme color for collapsed state
surfaceTintColor: Colors.transparent,
flexibleSpace: FlexibleSpaceBar(
background: Stack(
fit: StackFit.expand,
children: [
if (coverUrl != null)
CachedNetworkImage(imageUrl: coverUrl!, fit: BoxFit.cover, color: Colors.black.withValues(alpha: 0.5), colorBlendMode: BlendMode.darken, memCacheWidth: 600),
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, colorScheme.surface.withValues(alpha: 0.8), colorScheme.surface],
stops: const [0.0, 0.7, 1.0],
),
),
),
Center(
child: Padding(
padding: const EdgeInsets.only(top: 60),
child: Container(
width: 140,
height: 140,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 20, offset: const Offset(0, 10))],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: coverUrl != null
? CachedNetworkImage(imageUrl: coverUrl!, fit: BoxFit.cover, memCacheWidth: 280)
: Container(color: colorScheme.surfaceContainerHighest, child: Icon(Icons.playlist_play, size: 48, color: colorScheme.onSurfaceVariant)),
),
),
),
),
],
title: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: _showTitleInAppBar ? 1.0 : 0.0,
child: Text(
widget.playlistName,
style: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.w600,
fontSize: 16,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final collapseRatio = (constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight);
final showContent = collapseRatio > 0.3;
return FlexibleSpaceBar(
collapseMode: CollapseMode.none,
background: Stack(
fit: StackFit.expand,
children: [
// Background with dominant color
AnimatedContainer(
duration: const Duration(milliseconds: 500),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
bgColor,
bgColor.withValues(alpha: 0.8),
colorScheme.surface,
],
stops: const [0.0, 0.6, 1.0],
),
),
),
// Cover image centered - fade out when collapsing
AnimatedOpacity(
duration: const Duration(milliseconds: 150),
opacity: showContent ? 1.0 : 0.0,
child: Center(
child: Padding(
padding: const EdgeInsets.only(top: 60),
child: Container(
width: coverSize,
height: coverSize,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.4),
blurRadius: 30,
offset: const Offset(0, 15),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: widget.coverUrl != null
? CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
memCacheWidth: (coverSize * 2).toInt(),
cacheManager: CoverCacheManager.instance,
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.playlist_play, size: 64, color: colorScheme.onSurfaceVariant),
),
),
),
),
),
),
],
),
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
);
},
),
leading: IconButton(
icon: Container(padding: const EdgeInsets.all(8), decoration: BoxDecoration(color: colorScheme.surface.withValues(alpha: 0.8), shape: BoxShape.circle), child: Icon(Icons.arrow_back, color: colorScheme.onSurface)),
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.surface.withValues(alpha: 0.8),
shape: BoxShape.circle,
),
child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
),
onPressed: () => Navigator.pop(context),
),
);
}
Widget _buildInfoCard(BuildContext context, WidgetRef ref, ColorScheme colorScheme) {
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
@@ -105,7 +201,7 @@ class PlaylistScreen extends ConsumerWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(playlistName, style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface)),
Text(widget.playlistName, style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface)),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
@@ -115,16 +211,19 @@ class PlaylistScreen extends ConsumerWidget {
children: [
Icon(Icons.playlist_play, size: 14, color: colorScheme.onTertiaryContainer),
const SizedBox(width: 4),
Text(context.l10n.tracksCount(tracks.length), style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
Text(context.l10n.tracksCount(widget.tracks.length), style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
],
),
),
const SizedBox(height: 16),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: () => _downloadAll(context, ref),
icon: const Icon(Icons.download),
label: Text(context.l10n.downloadAllCount(tracks.length)),
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(52), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))),
onPressed: () => _downloadAll(context),
icon: const Icon(Icons.download, size: 18),
label: Text(context.l10n.downloadAllCount(widget.tracks.length)),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
),
),
],
),
@@ -149,25 +248,25 @@ class PlaylistScreen extends ConsumerWidget {
);
}
Widget _buildTrackList(BuildContext context, WidgetRef ref, ColorScheme colorScheme) {
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme) {
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final track = tracks[index];
final track = widget.tracks[index];
return KeyedSubtree(
key: ValueKey(track.id),
child: _PlaylistTrackItem(
track: track,
onDownload: () => _downloadTrack(context, ref, track),
onDownload: () => _downloadTrack(context, track),
),
);
},
childCount: tracks.length,
childCount: widget.tracks.length,
),
);
}
void _downloadTrack(BuildContext context, WidgetRef ref, Track track) {
void _downloadTrack(BuildContext context, Track track) {
final settings = ref.read(settingsProvider);
if (settings.askQualityBeforeDownload) {
DownloadServicePicker.show(
@@ -186,22 +285,22 @@ class PlaylistScreen extends ConsumerWidget {
}
}
void _downloadAll(BuildContext context, WidgetRef ref) {
if (tracks.isEmpty) return;
void _downloadAll(BuildContext context) {
if (widget.tracks.isEmpty) return;
final settings = ref.read(settingsProvider);
if (settings.askQualityBeforeDownload) {
DownloadServicePicker.show(
context,
trackName: '${tracks.length} tracks',
artistName: playlistName,
trackName: '${widget.tracks.length} tracks',
artistName: widget.playlistName,
onSelect: (quality, service) {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length))));
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(widget.tracks, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(widget.tracks.length))));
},
);
} else {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length))));
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(widget.tracks, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(widget.tracks.length))));
}
}
}
@@ -217,12 +316,10 @@ class _PlaylistTrackItem extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
// Only watch the specific item for this track
final queueItem = ref.watch(downloadQueueProvider.select((state) {
return state.items.where((item) => item.track.id == track.id).firstOrNull;
}));
final queueItem = ref.watch(
downloadQueueLookupProvider.select((lookup) => lookup.byTrackId[track.id]),
);
// Check if track is in history (already downloaded before)
final isInHistory = ref.watch(downloadHistoryProvider.select((state) {
return state.isDownloaded(track.id);
}));
@@ -233,7 +330,6 @@ class _PlaylistTrackItem extends ConsumerWidget {
final isCompleted = queueItem?.status == DownloadStatus.completed;
final progress = queueItem?.progress ?? 0.0;
// Show as downloaded if in queue completed OR in history
final showAsDownloaded = isCompleted || (!isQueued && isInHistory);
return Padding(
@@ -244,8 +340,8 @@ class _PlaylistTrackItem extends ConsumerWidget {
margin: const EdgeInsets.symmetric(vertical: 2),
child: ListTile(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
leading: track.coverUrl != null
? ClipRRect(borderRadius: BorderRadius.circular(8), child: CachedNetworkImage(imageUrl: track.coverUrl!, width: 48, height: 48, fit: BoxFit.cover, memCacheWidth: 96))
leading: track.coverUrl != null
? ClipRRect(borderRadius: BorderRadius.circular(8), child: CachedNetworkImage(imageUrl: track.coverUrl!, width: 48, height: 48, fit: BoxFit.cover, memCacheWidth: 96, cacheManager: CoverCacheManager.instance))
: Container(width: 48, height: 48, decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)), child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)),
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500)),
subtitle: Text(track.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant)),
+10 -7
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/services/cover_cache_manager.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/download_item.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
@@ -10,20 +11,20 @@ class QueueScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final queueState = ref.watch(downloadQueueProvider);
final items = ref.watch(downloadQueueProvider.select((s) => s.items));
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(
title: Text(context.l10n.queueTitle),
actions: [
if (queueState.items.isNotEmpty)
if (items.isNotEmpty)
IconButton(
icon: const Icon(Icons.delete_sweep),
onPressed: () => ref.read(downloadQueueProvider.notifier).clearCompleted(),
tooltip: context.l10n.queueClearCompleted,
),
if (queueState.items.isNotEmpty)
if (items.isNotEmpty)
IconButton(
icon: const Icon(Icons.clear_all),
onPressed: () => _showClearAllDialog(context, ref),
@@ -31,11 +32,12 @@ class QueueScreen extends ConsumerWidget {
),
],
),
body: queueState.items.isEmpty
body: items.isEmpty
? _buildEmptyState(context, colorScheme)
: ListView.builder(
itemCount: queueState.items.length,
itemBuilder: (context, index) => _buildQueueItem(context, ref, queueState.items[index], colorScheme),
itemCount: items.length,
itemBuilder: (context, index) =>
_buildQueueItem(context, ref, items[index], colorScheme),
),
);
}
@@ -74,11 +76,12 @@ class QueueScreen extends ConsumerWidget {
leading: item.track.coverUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
child: CachedNetworkImage(
imageUrl: item.track.coverUrl!,
width: 48,
height: 48,
fit: BoxFit.cover,
cacheManager: CoverCacheManager.instance,
),
)
: Container(
+466 -244
View File
File diff suppressed because it is too large Load Diff

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