mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-04 19:57:55 +02:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 24ef66be4c | |||
| d07a49f605 | |||
| 4eba28db7a | |||
| b73a3f8912 | |||
| 9f47f2ce85 | |||
| f2aca734a3 | |||
| 09cb637a86 | |||
| 11e7034cec | |||
| f12c18d76b | |||
| 0da39a1b8b | |||
| f29fe5054c | |||
| c8c0164964 | |||
| 52dd657913 | |||
| c30f9fe412 | |||
| bea5dd1d4a | |||
| 8726a0858a |
@@ -0,0 +1,123 @@
|
|||||||
|
name: Bug Report
|
||||||
|
description: Report a bug or unexpected behavior
|
||||||
|
title: "[Bug]: "
|
||||||
|
labels: ["bug"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for taking the time to report a bug! Please fill out the form below.
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: checklist
|
||||||
|
attributes:
|
||||||
|
label: Checklist
|
||||||
|
description: Please confirm the following before submitting
|
||||||
|
options:
|
||||||
|
- label: I have searched existing issues and this bug hasn't been reported yet
|
||||||
|
required: true
|
||||||
|
- label: I am using the latest version of SpotiFLAC
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
attributes:
|
||||||
|
label: Bug Description
|
||||||
|
description: A clear and concise description of what the bug is
|
||||||
|
placeholder: Describe the bug...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: steps
|
||||||
|
attributes:
|
||||||
|
label: Steps to Reproduce
|
||||||
|
description: Steps to reproduce the behavior
|
||||||
|
placeholder: |
|
||||||
|
1. Go to '...'
|
||||||
|
2. Click on '...'
|
||||||
|
3. See error
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: expected
|
||||||
|
attributes:
|
||||||
|
label: Expected Behavior
|
||||||
|
description: What did you expect to happen?
|
||||||
|
placeholder: Describe what you expected...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: actual
|
||||||
|
attributes:
|
||||||
|
label: Actual Behavior
|
||||||
|
description: What actually happened?
|
||||||
|
placeholder: Describe what actually happened...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: App Version
|
||||||
|
description: Which version of SpotiFLAC are you using? (Check in Settings > About)
|
||||||
|
placeholder: "e.g., v2.2.0"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: platform
|
||||||
|
attributes:
|
||||||
|
label: Platform
|
||||||
|
description: Which platform are you using?
|
||||||
|
options:
|
||||||
|
- Android
|
||||||
|
- iOS
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: device
|
||||||
|
attributes:
|
||||||
|
label: Device & OS Version
|
||||||
|
description: What device and OS version are you using?
|
||||||
|
placeholder: "e.g., Samsung Galaxy S24, Android 14"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: download-service
|
||||||
|
attributes:
|
||||||
|
label: Download Service
|
||||||
|
description: Which download service were you using when the bug occurred?
|
||||||
|
options:
|
||||||
|
- Tidal
|
||||||
|
- Qobuz
|
||||||
|
- Amazon Music
|
||||||
|
- Deezer (search only)
|
||||||
|
- Not applicable
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: logs
|
||||||
|
attributes:
|
||||||
|
label: Logs / Screenshots
|
||||||
|
description: |
|
||||||
|
If applicable, add logs or screenshots to help explain your problem.
|
||||||
|
|
||||||
|
**To get logs:**
|
||||||
|
1. Go to Settings > Options > Detailed Logging (turn ON)
|
||||||
|
2. Reproduce the bug
|
||||||
|
3. Go to Settings > Logs
|
||||||
|
4. Tap Share button to export logs
|
||||||
|
placeholder: Paste logs or drag & drop screenshots here...
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: additional
|
||||||
|
attributes:
|
||||||
|
label: Additional Context
|
||||||
|
description: Any other context about the problem
|
||||||
|
placeholder: Add any other context...
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: README
|
||||||
|
url: https://github.com/zarzet/SpotiFLAC-Mobile#readme
|
||||||
|
about: Check the README for setup instructions and FAQ
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
name: Download Issue
|
||||||
|
description: Report issues with downloading specific tracks or albums
|
||||||
|
title: "[Download]: "
|
||||||
|
labels: ["download-issue"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Having trouble downloading a specific track or album? Please provide details below.
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: checklist
|
||||||
|
attributes:
|
||||||
|
label: Checklist
|
||||||
|
description: Please confirm the following before submitting
|
||||||
|
options:
|
||||||
|
- label: I have tried downloading with a different service (Tidal/Qobuz/Amazon)
|
||||||
|
required: true
|
||||||
|
- label: I am using the latest version of SpotiFLAC
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: issue-type
|
||||||
|
attributes:
|
||||||
|
label: Issue Type
|
||||||
|
description: What kind of download issue are you experiencing?
|
||||||
|
options:
|
||||||
|
- Track not found on service
|
||||||
|
- Wrong track downloaded
|
||||||
|
- Download fails/errors
|
||||||
|
- Metadata incorrect
|
||||||
|
- Audio quality issue
|
||||||
|
- Other
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: spotify-url
|
||||||
|
attributes:
|
||||||
|
label: Spotify URL
|
||||||
|
description: The Spotify URL of the track/album you're trying to download
|
||||||
|
placeholder: "https://open.spotify.com/track/..."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: track-info
|
||||||
|
attributes:
|
||||||
|
label: Track Info
|
||||||
|
description: Artist name and track title
|
||||||
|
placeholder: "Artist - Track Title"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: download-service
|
||||||
|
attributes:
|
||||||
|
label: Download Service
|
||||||
|
description: Which service did you try to download from?
|
||||||
|
options:
|
||||||
|
- Tidal
|
||||||
|
- Qobuz
|
||||||
|
- Amazon Music
|
||||||
|
- All services
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: search-service
|
||||||
|
attributes:
|
||||||
|
label: Search Service
|
||||||
|
description: Which search service are you using?
|
||||||
|
options:
|
||||||
|
- Spotify
|
||||||
|
- Deezer
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
attributes:
|
||||||
|
label: Description
|
||||||
|
description: Describe the issue in detail
|
||||||
|
placeholder: |
|
||||||
|
What happened? What did you expect?
|
||||||
|
If wrong track was downloaded, what track was downloaded instead?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: App Version
|
||||||
|
description: Which version of SpotiFLAC are you using?
|
||||||
|
placeholder: "e.g., v2.2.0"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: screenshots
|
||||||
|
attributes:
|
||||||
|
label: Screenshots / Logs
|
||||||
|
description: |
|
||||||
|
If applicable, add screenshots or logs.
|
||||||
|
|
||||||
|
**To get logs:**
|
||||||
|
1. Go to Settings > Options > Detailed Logging (turn ON)
|
||||||
|
2. Try downloading the track again
|
||||||
|
3. Go to Settings > Logs
|
||||||
|
4. Tap Share button to export logs
|
||||||
|
placeholder: Drag & drop screenshots or paste logs here...
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
name: Feature Request
|
||||||
|
description: Suggest a new feature or improvement
|
||||||
|
title: "[Feature]: "
|
||||||
|
labels: ["enhancement"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for suggesting a feature! Please fill out the form below.
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: checklist
|
||||||
|
attributes:
|
||||||
|
label: Checklist
|
||||||
|
description: Please confirm the following before submitting
|
||||||
|
options:
|
||||||
|
- label: I have searched existing issues and this feature hasn't been requested yet
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: problem
|
||||||
|
attributes:
|
||||||
|
label: Problem / Motivation
|
||||||
|
description: Is your feature request related to a problem? Please describe.
|
||||||
|
placeholder: "A clear description of what the problem is. Ex: I'm always frustrated when..."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: solution
|
||||||
|
attributes:
|
||||||
|
label: Proposed Solution
|
||||||
|
description: Describe the solution you'd like
|
||||||
|
placeholder: A clear description of what you want to happen...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: alternatives
|
||||||
|
attributes:
|
||||||
|
label: Alternatives Considered
|
||||||
|
description: Describe any alternative solutions or features you've considered
|
||||||
|
placeholder: Other approaches you've thought about...
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: category
|
||||||
|
attributes:
|
||||||
|
label: Category
|
||||||
|
description: What category does this feature fall under?
|
||||||
|
options:
|
||||||
|
- UI/UX Improvement
|
||||||
|
- Download Feature
|
||||||
|
- New Service Integration
|
||||||
|
- Metadata/Tagging
|
||||||
|
- Performance
|
||||||
|
- Settings/Configuration
|
||||||
|
- Other
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: additional
|
||||||
|
attributes:
|
||||||
|
label: Additional Context
|
||||||
|
description: Add any other context, mockups, or screenshots about the feature request
|
||||||
|
placeholder: Add any other context or screenshots...
|
||||||
@@ -93,11 +93,12 @@ jobs:
|
|||||||
# Accept licenses
|
# Accept licenses
|
||||||
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true
|
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true
|
||||||
|
|
||||||
# Install NDK (required for gomobile)
|
# Install NDK r27d LTS (required for 16KB page size support on Android 15+)
|
||||||
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;25.2.9519653" "platforms;android-34" "build-tools;34.0.0"
|
# Platform android-36 and build-tools 36.0.0 for targetSdk 36 (Android 16)
|
||||||
|
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;27.3.13750724" "platforms;android-36" "build-tools;36.0.0"
|
||||||
|
|
||||||
# Set NDK path
|
# Set NDK path
|
||||||
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/25.2.9519653" >> $GITHUB_ENV
|
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/27.3.13750724" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Install gomobile
|
- name: Install gomobile
|
||||||
run: |
|
run: |
|
||||||
@@ -144,7 +145,7 @@ jobs:
|
|||||||
keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }}
|
keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||||
keyPassword: ${{ secrets.KEY_PASSWORD }}
|
keyPassword: ${{ secrets.KEY_PASSWORD }}
|
||||||
env:
|
env:
|
||||||
BUILD_TOOLS_VERSION: "34.0.0"
|
BUILD_TOOLS_VERSION: "36.0.0"
|
||||||
|
|
||||||
- name: Rename APKs
|
- name: Rename APKs
|
||||||
run: |
|
run: |
|
||||||
@@ -382,20 +383,17 @@ jobs:
|
|||||||
### Downloads
|
### Downloads
|
||||||
|
|
||||||
#### Android
|
#### Android
|
||||||

|
|
||||||

|
|
||||||
|
|
||||||
- **arm64**: \`SpotiFLAC-${VERSION}-arm64.apk\` (recommended for modern devices)
|
- **arm64**: \`SpotiFLAC-${VERSION}-arm64.apk\` (recommended for modern devices)
|
||||||
- **arm32**: \`SpotiFLAC-${VERSION}-arm32.apk\` (older devices)
|
- **arm32**: \`SpotiFLAC-${VERSION}-arm32.apk\` (older devices)
|
||||||
|
|
||||||
#### iOS
|
#### iOS
|
||||||

|
|
||||||
|
|
||||||
- **iOS**: \`SpotiFLAC-${VERSION}-ios-unsigned.ipa\` (sideload required)
|
- **iOS**: \`SpotiFLAC-${VERSION}-ios-unsigned.ipa\` (sideload required)
|
||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
**Android**: Enable "Install from unknown sources" and install the APK
|
**Android**: Enable "Install from unknown sources" and install the APK
|
||||||
**iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA
|
**iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA
|
||||||
|
|
||||||
|
  
|
||||||
FOOTER
|
FOOTER
|
||||||
|
|
||||||
echo "Release body:"
|
echo "Release body:"
|
||||||
|
|||||||
+225
@@ -1,5 +1,230 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [2.2.7] - 2026-01-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **CSV Import Metadata Enrichment**: Tracks imported from CSV now automatically fetch metadata from Deezer
|
||||||
|
- Cover art, duration, track/disc number fetched via ISRC lookup
|
||||||
|
- Fallback to text search (artist + track name) when ISRC not found in Deezer
|
||||||
|
- Progress dialog shows enrichment status during import
|
||||||
|
- Ensures downloaded files have proper cover art and metadata
|
||||||
|
- **Deezer Metadata Support**: Enhanced metadata viewer for Deezer tracks
|
||||||
|
- "Open in Deezer" button for Deezer-sourced tracks (opens app or web)
|
||||||
|
- Displays "Deezer ID" instead of "Spotify ID" when applicable
|
||||||
|
- **Smart Tag Injection**: Filename format editor intelligently handles separators
|
||||||
|
- Auto-detects if " - " is needed between tags
|
||||||
|
- Prevents double separators or missing spaces
|
||||||
|
- **Dynamic Source Info**: Search source selector now shows helpful context
|
||||||
|
- "No login required" for Deezer
|
||||||
|
- "Requires credentials" for Spotify
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **UI Modernization**: Major UI consistency updates across the app
|
||||||
|
- **Unified App Bars**: Home, History, and Settings now share identical behavior
|
||||||
|
- Lowered expanded header for easier one-handed reachability
|
||||||
|
- Dynamic title text scaling (20px to 34px)
|
||||||
|
- **Appearance Settings**: Completely redesigned appearance page
|
||||||
|
- New "Theme Preview" card showing visualizing current theme
|
||||||
|
- Modern color palette picker replacing old color dots
|
||||||
|
- Clean, grouped layout
|
||||||
|
- "AMOLED Dark" switch is now hidden when using Light Mode
|
||||||
|
- **App Logo**: Refined logo style on Home and About screens
|
||||||
|
- Inverted colors: Filled primary color circle with on-color icon
|
||||||
|
- Removed padding for a cleaner, bolder look
|
||||||
|
- **Material 3 Switches**: Added checkmark icon to active switches
|
||||||
|
- **UI Modernization (Global)**: Complete design refresh for a cleaner, modern look
|
||||||
|
- **Rounded Corners**: Standardized 16px radius for all cards, buttons, and input fields
|
||||||
|
- **Transparent Elements**: Applied subtle transparency to input fields and containers using `surfaceContainerHighest`
|
||||||
|
- **Consistent Buttons**: Unified button styling across the app (pill shape, 16px radius)
|
||||||
|
- **Options Settings Redesign**: improved layout and usability
|
||||||
|
- **Search Source Priority**: Moved "Search Source" section to the very top for quick access
|
||||||
|
- **Compact Source Selector**: Redesigned provider toggle (Deezer/Spotify) to be compact and consistent
|
||||||
|
- **Credentials Workflow**: Reorganized Custom Credentials settings; toggle now auto-prompts if credentials missing
|
||||||
|
- **Modern Credentials Dialog**: Totally redesigned input dialog for Spotify Client ID/Secret
|
||||||
|
- **Filename Format Editor 2.0**:
|
||||||
|
- **Modern Sheet UI**: Replaced legacy dialog with a clean, full-width bottom sheet
|
||||||
|
- **Tag Chips**: Added clickable chips ({artist}, {title}) for one-tap insertion
|
||||||
|
- **Smart Formatting**: Automatically injects separators (" - ") when adding tags for faster editing
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **CSV Import Missing Cover Art**: Fixed tracks from CSV having no cover art in download history
|
||||||
|
- Cover URL now properly fetched from Deezer during enrichment
|
||||||
|
- Falls back to text search when ISRC lookup fails
|
||||||
|
- **CSV Import Missing Duration**: Fixed duration showing 0:00 for CSV-imported tracks
|
||||||
|
- Duration now fetched from Deezer metadata during enrichment
|
||||||
|
- **Disc Number Not Displayed**: Fixed disc number not showing in track metadata screen
|
||||||
|
- Changed condition from `discNumber > 0` to `discNumber > 0`
|
||||||
|
- Now displays disc 1 instead of hiding it
|
||||||
|
- **Download History Using Wrong Track Data**: Fixed history using original CSV data instead of enriched data
|
||||||
|
- Now uses `trackToDownload` (enriched) instead of `item.track` (original)
|
||||||
|
|
||||||
|
### Technical
|
||||||
|
|
||||||
|
- Updated `lib/services/csv_import_service.dart`:
|
||||||
|
- Added `_enrichTracksMetadata()` with ISRC lookup + text search fallback
|
||||||
|
- Added progress callback for UI feedback
|
||||||
|
- Updated `lib/screens/home_tab.dart`:
|
||||||
|
- Added progress dialog during CSV enrichment
|
||||||
|
- Updated `lib/providers/download_queue_provider.dart`:
|
||||||
|
- Uses enriched track data for download history
|
||||||
|
- Updated `lib/screens/track_metadata_screen.dart`:
|
||||||
|
- Show disc number when > 0 (was > 1)
|
||||||
|
- Updated `go_backend/metadata.go`:
|
||||||
|
- Added `TotalSamples` to `AudioQuality` struct for duration calculation
|
||||||
|
- Updated `go_backend/exports.go`:
|
||||||
|
- `ReadFileMetadata` now returns duration calculated from FLAC stream info
|
||||||
|
- Updated `AppTheme` with new `InputDecorationTheme` and `ButtonTheme` definitions
|
||||||
|
- Refactored `DownloadSettingsPage` to use new `_showFormatEditor` with cursor-aware capabilities
|
||||||
|
- Optimized various dialogs to use `showModalBottomSheet` with `isScrollControlled` for better keyboard handling
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.2.6] - 2026-01-11
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Release Mode Logging**: Flutter app logs now properly captured in release builds
|
||||||
|
- Previously only Go backend logs appeared when "Detailed Logging" was enabled
|
||||||
|
- Now both Flutter and Go logs are captured in release mode
|
||||||
|
- Bypasses Logger package which filters logs in release mode
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Detailed Deezer Search Logging**: Better debugging for search issues
|
||||||
|
- Logs API URLs, response counts, and errors
|
||||||
|
- Helps diagnose geo-restriction and API issues
|
||||||
|
- Detects Deezer API error responses
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Home Screen Logo**: Replaced music note icon with app logo
|
||||||
|
- Uses `assets/images/logo.png`
|
||||||
|
- Rounded corners (24px radius)
|
||||||
|
- Fallback to music note icon if logo fails to load
|
||||||
|
- **About Page Logo**: Removed shadow/border from logo
|
||||||
|
- Cleaner appearance without background container
|
||||||
|
- **About Page Icon Alignment**: Icons now aligned with contributor avatars
|
||||||
|
- DoubleDouble and DAB Music icons use 40x40 area
|
||||||
|
- Text now properly aligned with contributor items
|
||||||
|
|
||||||
|
## [2.2.5] - 2026-01-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **In-App Log Viewer with Go Backend Logs**: Complete logging system for debugging
|
||||||
|
- Go backend logs now captured and displayed in app
|
||||||
|
- Circular buffer stores up to 500 log entries
|
||||||
|
- Real-time polling (500ms) for Go backend logs
|
||||||
|
- Logs include timestamp, level, tag, and message
|
||||||
|
- "Go" badge indicates logs from backend
|
||||||
|
- **Detailed Logging Toggle**: Control logging in Settings > Options > Debug
|
||||||
|
- Disabled by default for performance
|
||||||
|
- Errors are always logged regardless of setting
|
||||||
|
- Enable before reproducing bugs for detailed logs
|
||||||
|
- **Log Issue Summary**: Automatic detection of common issues in logs
|
||||||
|
- ISP Blocking detection with affected domains
|
||||||
|
- Rate limiting detection
|
||||||
|
- Network error detection
|
||||||
|
- Track not found detection
|
||||||
|
- Shows suggestions for each issue type
|
||||||
|
- **ISP Blocking Detection**: Detects when ISP blocks download services
|
||||||
|
- DNS resolution failure detection
|
||||||
|
- Connection reset/refused detection
|
||||||
|
- TLS handshake failure detection
|
||||||
|
- HTTP 403/451 blocking page detection
|
||||||
|
- Suggests VPN or DNS change (1.1.1.1 / 8.8.8.8)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Artist Profile Placeholder**: Shows person icon when artist has no profile image
|
||||||
|
- Validates image URL before loading
|
||||||
|
- Fallback icon on load error
|
||||||
|
- **Latin Extended Character Detection**: Fixed wrong track downloads for Polish, Czech, French, Spanish songs
|
||||||
|
- Characters like Ł, ę, ć, ñ, é now correctly treated as Latin script
|
||||||
|
- Previously treated as "different script" causing false matches
|
||||||
|
- Affects both Tidal and Qobuz search
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Log Screen UI Improvements**:
|
||||||
|
- Copy button moved to app bar (left of menu)
|
||||||
|
- Removed redundant info card
|
||||||
|
- Cleaner interface
|
||||||
|
- **Issue Templates Updated**: Instructions for enabling detailed logging before submitting bug reports
|
||||||
|
|
||||||
|
### Technical
|
||||||
|
|
||||||
|
- New file: `go_backend/logbuffer.go` with circular buffer and GoLog function
|
||||||
|
- Updated `go_backend/httputil.go` with ISP blocking detection
|
||||||
|
- Updated `go_backend/tidal.go` and `go_backend/qobuz.go` with `isLatinScript()` function
|
||||||
|
- Updated `lib/utils/logger.dart` with Go log polling
|
||||||
|
- Updated `lib/screens/settings/log_screen.dart` with issue summary
|
||||||
|
- Added method channel handlers for logging in Android and iOS
|
||||||
|
- New error type: `isp_blocked` for ISP blocking errors
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.2.0] - 2026-01-10
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **ISRC Metadata Missing:** Fixed an issue where ISRC codes were not being saved to the download history or embedded in file metadata for certain downloads. The backend now correctly propagates the ISRC found from streaming services (Tidal, Qobuz, Amazon) back to the application.
|
||||||
|
- **Tidal Track/Disc Numbers:** Fixed missing Track Number and Disc Number in Tidal downloads. The downloader now prioritizes the actual metadata returned by Tidal over the potentially incomplete metadata from the initial search request.
|
||||||
|
- **Concurrent Download Race Condition:** Fixed a potential race condition where temporary cover art files could overwrite each other during rapid concurrent downloads by adding randomization to temporary filenames.
|
||||||
|
- **Qobuz Search Accuracy:** Reduced the duration tolerance for Qobuz search matches from 30s to 10s to prevent matching with incorrect versions/remixes.
|
||||||
|
- **Metadata Enrichment Null Safety**: Fixed `type 'Null' is not a subtype of type 'String'` error
|
||||||
|
- Added proper null checks when parsing Go backend response
|
||||||
|
- Added type checking for track data before parsing
|
||||||
|
- **Duration Calculation in Enrichment**: Fixed duration conversion bug
|
||||||
|
- Go backend returns `duration_ms` (milliseconds)
|
||||||
|
- Now properly converts to seconds for Track model
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Default Service Priority:** Updated the default download fallback order to **Tidal → Qobuz → Amazon**.
|
||||||
|
- Tidal is now the default download service (was Qobuz)
|
||||||
|
- Tidal has faster and more reliable ISRC matching
|
||||||
|
- Existing users need to change setting manually or clear app data
|
||||||
|
- **Metadata Enrichment:** Improved metadata handling for Deezer tracks. If critical metadata (ISRC, Track Number) is missing from the initial search, the app now automatically fetches full details from the Deezer API before finding a source.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **ISRC in History:** The Download History now reliably displays the ISRC code for downloaded tracks.
|
||||||
|
- **Tidal Search Optimization:** Optimized Tidal search logic to immediately check for ISRC matches within search results, improving match speed and accuracy.
|
||||||
|
- Returns as soon as ISRC match is found in first query results
|
||||||
|
- Significantly faster for tracks with valid ISRC
|
||||||
|
- **ISRC Enrichment for Search Results**: Tracks from Home search now fetch ISRC before download
|
||||||
|
- Search results don't include ISRC (for performance)
|
||||||
|
- ISRC is now fetched via metadata enrichment when download starts
|
||||||
|
- Ensures accurate track matching on all streaming services
|
||||||
|
- **Deezer-to-Tidal Fallback:** Added native support for converting Deezer IDs to Tidal links via SongLink when using the fallback mechanism.
|
||||||
|
- **Better Logging for Qobuz ISRC Search**: Added detailed logs for debugging
|
||||||
|
- Shows when ISRC search is attempted
|
||||||
|
- Shows number of results and exact ISRC matches found
|
||||||
|
|
||||||
|
### Technical
|
||||||
|
|
||||||
|
- Updated `go_backend/tidal.go`:
|
||||||
|
- Early exit optimization in `SearchTrackByMetadataWithISRC()`
|
||||||
|
- Deezer ID support in SongLink lookup
|
||||||
|
- Updated `go_backend/qobuz.go`:
|
||||||
|
- Added logging for ISRC search flow
|
||||||
|
- Duration tolerance reduced from 30s to 10s
|
||||||
|
- Updated `go_backend/exports.go`:
|
||||||
|
- Default service order changed to `[tidal, qobuz, amazon]`
|
||||||
|
- Updated `lib/providers/download_queue_provider.dart`:
|
||||||
|
- ISRC-based enrichment condition
|
||||||
|
- Null-safe parsing of Go backend response
|
||||||
|
- Updated `lib/services/platform_bridge.dart`:
|
||||||
|
- Null check for `getDeezerMetadata` result
|
||||||
|
- Updated `lib/models/settings.dart`:
|
||||||
|
- Default service changed to `tidal`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [2.1.7] - 2026-01-09
|
## [2.1.7] - 2026-01-09
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||||
[](https://www.virustotal.com/gui/file/ca16289599f71b8e50d3726a8c64a202ea922a1893bcf21b9eca1a050736f1f5/)
|
[](https://www.virustotal.com/gui/file/09c6260e9ebaf2ff0d15f30deda939642f41887f11aad602ac697cb37fa0308c/)
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ android {
|
|||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.zarz.spotiflac"
|
applicationId = "com.zarz.spotiflac"
|
||||||
minSdk = flutter.minSdkVersion
|
minSdk = flutter.minSdkVersion
|
||||||
targetSdk = 34
|
targetSdk = 36
|
||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
multiDexEnabled = true
|
multiDexEnabled = true
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ import androidx.core.app.NotificationCompat
|
|||||||
/**
|
/**
|
||||||
* Foreground service to keep downloads running when app is in background.
|
* Foreground service to keep downloads running when app is in background.
|
||||||
* This prevents Android from killing the download process or throttling network.
|
* This prevents Android from killing the download process or throttling network.
|
||||||
|
*
|
||||||
|
* Note: Android 15+ (API 35+) has a 6-hour timeout for dataSync foreground services.
|
||||||
|
* The service will be stopped automatically after 6 hours of cumulative runtime in 24 hours.
|
||||||
*/
|
*/
|
||||||
class DownloadService : Service() {
|
class DownloadService : Service() {
|
||||||
|
|
||||||
@@ -106,6 +109,19 @@ class DownloadService : Service() {
|
|||||||
|
|
||||||
override fun onBind(intent: Intent?): IBinder? = null
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the foreground service timeout is reached (Android 15+, API 35+).
|
||||||
|
* dataSync services have a 6-hour limit in a 24-hour period.
|
||||||
|
* We must call stopSelf() within a few seconds to avoid a crash.
|
||||||
|
*/
|
||||||
|
override fun onTimeout(startId: Int, fgsType: Int) {
|
||||||
|
// Log the timeout for debugging
|
||||||
|
android.util.Log.w("DownloadService", "Foreground service timeout reached (6 hours limit). Stopping service.")
|
||||||
|
|
||||||
|
// Gracefully stop the service
|
||||||
|
stopForegroundService()
|
||||||
|
}
|
||||||
|
|
||||||
private fun createNotificationChannel() {
|
private fun createNotificationChannel() {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
val channel = NotificationChannel(
|
val channel = NotificationChannel(
|
||||||
|
|||||||
@@ -180,6 +180,13 @@ class MainActivity: FlutterActivity() {
|
|||||||
}
|
}
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
|
"readFileMetadata" -> {
|
||||||
|
val filePath = call.argument<String>("file_path") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.readFileMetadata(filePath)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
"startDownloadService" -> {
|
"startDownloadService" -> {
|
||||||
val trackName = call.argument<String>("track_name") ?: ""
|
val trackName = call.argument<String>("track_name") ?: ""
|
||||||
val artistName = call.argument<String>("artist_name") ?: ""
|
val artistName = call.argument<String>("artist_name") ?: ""
|
||||||
@@ -277,6 +284,39 @@ class MainActivity: FlutterActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
|
// Log methods
|
||||||
|
"getLogs" -> {
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.getLogs()
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"getLogsSince" -> {
|
||||||
|
val index = call.argument<Int>("index") ?: 0
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.getLogsSince(index.toLong())
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"clearLogs" -> {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.clearLogs()
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"getLogCount" -> {
|
||||||
|
val count = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.getLogCount()
|
||||||
|
}
|
||||||
|
result.success(count.toInt())
|
||||||
|
}
|
||||||
|
"setLoggingEnabled" -> {
|
||||||
|
val enabled = call.argument<Boolean>("enabled") ?: false
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.setLoggingEnabled(enabled)
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 69 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 70 KiB |
+66
-34
@@ -18,7 +18,7 @@ import (
|
|||||||
// AmazonDownloader handles Amazon Music downloads using DoubleDouble service (same as PC)
|
// AmazonDownloader handles Amazon Music downloads using DoubleDouble service (same as PC)
|
||||||
type AmazonDownloader struct {
|
type AmazonDownloader struct {
|
||||||
client *http.Client
|
client *http.Client
|
||||||
regions []string // us, eu regions for DoubleDouble service
|
regions []string // us, eu regions for DoubleDouble service
|
||||||
lastAPICallTime time.Time // Rate limiting: track last API call
|
lastAPICallTime time.Time // Rate limiting: track last API call
|
||||||
apiCallCount int // Rate limiting: counter per minute
|
apiCallCount int // Rate limiting: counter per minute
|
||||||
apiCallResetTime time.Time // Rate limiting: reset time
|
apiCallResetTime time.Time // Rate limiting: reset time
|
||||||
@@ -88,7 +88,7 @@ func amazonArtistsMatch(expectedArtist, foundArtist string) bool {
|
|||||||
expectedASCII := amazonIsASCIIString(expectedArtist)
|
expectedASCII := amazonIsASCIIString(expectedArtist)
|
||||||
foundASCII := amazonIsASCIIString(foundArtist)
|
foundASCII := amazonIsASCIIString(foundArtist)
|
||||||
if expectedASCII != foundASCII {
|
if expectedASCII != foundASCII {
|
||||||
fmt.Printf("[Amazon] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist)
|
GoLog("[Amazon] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,7 +135,7 @@ func (a *AmazonDownloader) waitForRateLimit() {
|
|||||||
if a.apiCallCount >= 9 {
|
if a.apiCallCount >= 9 {
|
||||||
waitTime := time.Minute - now.Sub(a.apiCallResetTime)
|
waitTime := time.Minute - now.Sub(a.apiCallResetTime)
|
||||||
if waitTime > 0 {
|
if waitTime > 0 {
|
||||||
fmt.Printf("[Amazon] Rate limit reached, waiting %v...\n", waitTime.Round(time.Second))
|
GoLog("[Amazon] Rate limit reached, waiting %v...\n", waitTime.Round(time.Second))
|
||||||
time.Sleep(waitTime)
|
time.Sleep(waitTime)
|
||||||
a.apiCallCount = 0
|
a.apiCallCount = 0
|
||||||
a.apiCallResetTime = time.Now()
|
a.apiCallResetTime = time.Now()
|
||||||
@@ -148,7 +148,7 @@ func (a *AmazonDownloader) waitForRateLimit() {
|
|||||||
minDelay := 7 * time.Second
|
minDelay := 7 * time.Second
|
||||||
if timeSinceLastCall < minDelay {
|
if timeSinceLastCall < minDelay {
|
||||||
waitTime := minDelay - timeSinceLastCall
|
waitTime := minDelay - timeSinceLastCall
|
||||||
fmt.Printf("[Amazon] Rate limiting: waiting %v...\n", waitTime.Round(time.Second))
|
GoLog("[Amazon] Rate limiting: waiting %v...\n", waitTime.Round(time.Second))
|
||||||
time.Sleep(waitTime)
|
time.Sleep(waitTime)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -170,7 +170,6 @@ func (a *AmazonDownloader) GetAvailableAPIs() []string {
|
|||||||
return apis
|
return apis
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// downloadFromDoubleDoubleService downloads a track using DoubleDouble service (same as PC)
|
// downloadFromDoubleDoubleService downloads a track using DoubleDouble service (same as PC)
|
||||||
// This uses submit → poll → download mechanism
|
// This uses submit → poll → download mechanism
|
||||||
// Internal function - not exported to gomobile
|
// Internal function - not exported to gomobile
|
||||||
@@ -178,11 +177,11 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir
|
|||||||
var lastError error
|
var lastError error
|
||||||
|
|
||||||
for _, region := range a.regions {
|
for _, region := range a.regions {
|
||||||
fmt.Printf("[Amazon] Trying region: %s...\n", region)
|
GoLog("[Amazon] Trying region: %s...\n", region)
|
||||||
|
|
||||||
// Build base URL for DoubleDouble service
|
// Build base URL for DoubleDouble service
|
||||||
// Decode base64 service URL (same as PC)
|
// Decode base64 service URL (same as PC)
|
||||||
serviceBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly8=") // https://
|
serviceBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly8=") // https://
|
||||||
serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=") // .doubledouble.top
|
serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=") // .doubledouble.top
|
||||||
baseURL := fmt.Sprintf("%s%s%s", string(serviceBase), region, string(serviceDomain))
|
baseURL := fmt.Sprintf("%s%s%s", string(serviceBase), region, string(serviceDomain))
|
||||||
|
|
||||||
@@ -217,7 +216,7 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir
|
|||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
if retry < maxRetries-1 {
|
if retry < maxRetries-1 {
|
||||||
waitTime := 15 * time.Second
|
waitTime := 15 * time.Second
|
||||||
fmt.Printf("[Amazon] Rate limited (429), waiting %v before retry %d/%d...\n", waitTime, retry+2, maxRetries)
|
GoLog("[Amazon] Rate limited (429), waiting %v before retry %d/%d...\n", waitTime, retry+2, maxRetries)
|
||||||
time.Sleep(waitTime)
|
time.Sleep(waitTime)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -256,7 +255,7 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir
|
|||||||
}
|
}
|
||||||
|
|
||||||
downloadID := submitResp.ID
|
downloadID := submitResp.ID
|
||||||
fmt.Printf("[Amazon] Download ID: %s\n", downloadID)
|
GoLog("[Amazon] Download ID: %s\n", downloadID)
|
||||||
|
|
||||||
// Step 2: Poll for completion
|
// Step 2: Poll for completion
|
||||||
statusURL := fmt.Sprintf("%s/dl/%s", baseURL, downloadID)
|
statusURL := fmt.Sprintf("%s/dl/%s", baseURL, downloadID)
|
||||||
@@ -311,7 +310,7 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir
|
|||||||
trackName := status.Current.Name
|
trackName := status.Current.Name
|
||||||
artist := status.Current.Artist
|
artist := status.Current.Artist
|
||||||
|
|
||||||
fmt.Printf("[Amazon] Downloading: %s - %s\n", artist, trackName)
|
GoLog("[Amazon] Downloading: %s - %s\n", artist, trackName)
|
||||||
return fileURL, trackName, artist, nil
|
return fileURL, trackName, artist, nil
|
||||||
|
|
||||||
} else if status.Status == "error" {
|
} else if status.Status == "error" {
|
||||||
@@ -345,7 +344,6 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir
|
|||||||
return "", "", "", fmt.Errorf("all regions failed. Last error: %v", lastError)
|
return "", "", "", fmt.Errorf("all regions failed. Last error: %v", lastError)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// DownloadFile downloads a file from URL with User-Agent and progress tracking
|
// DownloadFile downloads a file from URL with User-Agent and progress tracking
|
||||||
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||||
// Initialize item progress (required for all downloads)
|
// Initialize item progress (required for all downloads)
|
||||||
@@ -434,6 +432,7 @@ type AmazonDownloadResult struct {
|
|||||||
ReleaseDate string
|
ReleaseDate string
|
||||||
TrackNumber int
|
TrackNumber int
|
||||||
DiscNumber int
|
DiscNumber int
|
||||||
|
ISRC string
|
||||||
}
|
}
|
||||||
|
|
||||||
// downloadFromAmazon downloads a track using the request parameters
|
// downloadFromAmazon downloads a track using the request parameters
|
||||||
@@ -455,7 +454,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
if strings.HasPrefix(req.SpotifyID, "deezer:") {
|
if strings.HasPrefix(req.SpotifyID, "deezer:") {
|
||||||
// Extract Deezer ID and use Deezer-based lookup
|
// Extract Deezer ID and use Deezer-based lookup
|
||||||
deezerID := strings.TrimPrefix(req.SpotifyID, "deezer:")
|
deezerID := strings.TrimPrefix(req.SpotifyID, "deezer:")
|
||||||
fmt.Printf("[Amazon] Using Deezer ID for SongLink lookup: %s\n", deezerID)
|
GoLog("[Amazon] Using Deezer ID for SongLink lookup: %s\n", deezerID)
|
||||||
availability, err = songlink.CheckAvailabilityFromDeezer(deezerID)
|
availability, err = songlink.CheckAvailabilityFromDeezer(deezerID)
|
||||||
} else if req.SpotifyID != "" {
|
} else if req.SpotifyID != "" {
|
||||||
// Use Spotify ID
|
// Use Spotify ID
|
||||||
@@ -487,12 +486,12 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
|
|
||||||
// Verify artist matches
|
// Verify artist matches
|
||||||
if artistName != "" && !amazonArtistsMatch(req.ArtistName, artistName) {
|
if artistName != "" && !amazonArtistsMatch(req.ArtistName, artistName) {
|
||||||
fmt.Printf("[Amazon] Artist mismatch: expected '%s', got '%s'. Rejecting.\n", req.ArtistName, artistName)
|
GoLog("[Amazon] Artist mismatch: expected '%s', got '%s'. Rejecting.\n", req.ArtistName, artistName)
|
||||||
return AmazonDownloadResult{}, fmt.Errorf("artist mismatch: expected '%s', got '%s'", req.ArtistName, artistName)
|
return AmazonDownloadResult{}, fmt.Errorf("artist mismatch: expected '%s', got '%s'", req.ArtistName, artistName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log match found
|
// Log match found
|
||||||
fmt.Printf("[Amazon] Match found: '%s' by '%s'\n", trackName, artistName)
|
GoLog("[Amazon] Match found: '%s' by '%s'\n", trackName, artistName)
|
||||||
|
|
||||||
// Build filename using Spotify metadata (more accurate)
|
// Build filename using Spotify metadata (more accurate)
|
||||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
||||||
@@ -543,19 +542,38 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
|
|
||||||
// Log track info from DoubleDouble (for debugging)
|
// Log track info from DoubleDouble (for debugging)
|
||||||
if trackName != "" && artistName != "" {
|
if trackName != "" && artistName != "" {
|
||||||
fmt.Printf("[Amazon] DoubleDouble returned: %s - %s\n", artistName, trackName)
|
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)
|
||||||
|
}
|
||||||
|
if existingMeta.DiscNumber > 0 && (req.DiscNumber == 0 || req.DiscNumber == 1) {
|
||||||
|
actualDiscNum = existingMeta.DiscNumber
|
||||||
|
GoLog("[Amazon] Using disc number from file: %d (request had: %d)\n", actualDiscNum, req.DiscNumber)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Embed metadata using Spotify data (more accurate than DoubleDouble)
|
// Embed metadata using Spotify data (more accurate than DoubleDouble)
|
||||||
|
// But preserve track/disc numbers from file if they were better
|
||||||
metadata := Metadata{
|
metadata := Metadata{
|
||||||
Title: req.TrackName,
|
Title: req.TrackName,
|
||||||
Artist: req.ArtistName,
|
Artist: req.ArtistName,
|
||||||
Album: req.AlbumName,
|
Album: req.AlbumName,
|
||||||
AlbumArtist: req.AlbumArtist,
|
AlbumArtist: req.AlbumArtist,
|
||||||
Date: req.ReleaseDate,
|
Date: req.ReleaseDate,
|
||||||
TrackNumber: req.TrackNumber,
|
TrackNumber: actualTrackNum,
|
||||||
TotalTracks: req.TotalTracks,
|
TotalTracks: req.TotalTracks,
|
||||||
DiscNumber: req.DiscNumber,
|
DiscNumber: actualDiscNum,
|
||||||
ISRC: req.ISRC,
|
ISRC: req.ISRC,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -563,7 +581,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
var coverData []byte
|
var coverData []byte
|
||||||
if parallelResult != nil && parallelResult.CoverData != nil {
|
if parallelResult != nil && parallelResult.CoverData != nil {
|
||||||
coverData = parallelResult.CoverData
|
coverData = parallelResult.CoverData
|
||||||
fmt.Printf("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
GoLog("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
||||||
@@ -572,9 +590,9 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
|
|
||||||
// Embed lyrics from parallel fetch
|
// Embed lyrics from parallel fetch
|
||||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||||
fmt.Printf("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
||||||
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
|
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
|
||||||
fmt.Printf("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
|
GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||||
} else {
|
} else {
|
||||||
fmt.Println("[Amazon] Lyrics embedded successfully")
|
fmt.Println("[Amazon] Lyrics embedded successfully")
|
||||||
}
|
}
|
||||||
@@ -588,31 +606,45 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
// Amazon API doesn't provide quality info, but we can read it from the file itself
|
// Amazon API doesn't provide quality info, but we can read it from the file itself
|
||||||
quality, err := GetAudioQuality(outputPath)
|
quality, err := GetAudioQuality(outputPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("[Amazon] Warning: couldn't read quality from file: %v\n", err)
|
GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err)
|
||||||
// Add to ISRC index for fast duplicate checking
|
} else {
|
||||||
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
|
GoLog("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||||
// Return 0 to indicate unknown quality
|
|
||||||
return AmazonDownloadResult{
|
|
||||||
FilePath: outputPath,
|
|
||||||
BitDepth: 0,
|
|
||||||
SampleRate: 0,
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("[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",
|
||||||
|
finalMeta.TrackNumber, finalMeta.DiscNumber, finalMeta.Date)
|
||||||
|
actualTrackNum = finalMeta.TrackNumber
|
||||||
|
actualDiscNum = finalMeta.DiscNumber
|
||||||
|
if finalMeta.Date != "" {
|
||||||
|
// Use date from file if available
|
||||||
|
req.ReleaseDate = finalMeta.Date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Add to ISRC index for fast duplicate checking
|
// Add to ISRC index for fast duplicate checking
|
||||||
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
|
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
|
||||||
|
|
||||||
|
bitDepth := 0
|
||||||
|
sampleRate := 0
|
||||||
|
if err == nil {
|
||||||
|
bitDepth = quality.BitDepth
|
||||||
|
sampleRate = quality.SampleRate
|
||||||
|
}
|
||||||
|
|
||||||
return AmazonDownloadResult{
|
return AmazonDownloadResult{
|
||||||
FilePath: outputPath,
|
FilePath: outputPath,
|
||||||
BitDepth: quality.BitDepth,
|
BitDepth: bitDepth,
|
||||||
SampleRate: quality.SampleRate,
|
SampleRate: sampleRate,
|
||||||
Title: req.TrackName,
|
Title: req.TrackName,
|
||||||
Artist: req.ArtistName,
|
Artist: req.ArtistName,
|
||||||
Album: req.AlbumName,
|
Album: req.AlbumName,
|
||||||
ReleaseDate: req.ReleaseDate,
|
ReleaseDate: req.ReleaseDate,
|
||||||
TrackNumber: req.TrackNumber,
|
TrackNumber: actualTrackNum,
|
||||||
DiscNumber: req.DiscNumber,
|
DiscNumber: actualDiscNum,
|
||||||
|
ISRC: req.ISRC,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+76
-43
@@ -58,27 +58,27 @@ func GetDeezerClient() *DeezerClient {
|
|||||||
|
|
||||||
// Deezer API response types
|
// Deezer API response types
|
||||||
type deezerTrack struct {
|
type deezerTrack struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Duration int `json:"duration"` // in seconds
|
Duration int `json:"duration"` // in seconds
|
||||||
TrackPosition int `json:"track_position"`
|
TrackPosition int `json:"track_position"`
|
||||||
DiskNumber int `json:"disk_number"`
|
DiskNumber int `json:"disk_number"`
|
||||||
ISRC string `json:"isrc"`
|
ISRC string `json:"isrc"`
|
||||||
Link string `json:"link"`
|
Link string `json:"link"`
|
||||||
ReleaseDate string `json:"release_date"` // Sometimes at track level
|
ReleaseDate string `json:"release_date"` // Sometimes at track level
|
||||||
Artist deezerArtist `json:"artist"`
|
Artist deezerArtist `json:"artist"`
|
||||||
Album deezerAlbumSimple `json:"album"`
|
Album deezerAlbumSimple `json:"album"`
|
||||||
Contributors []deezerArtist `json:"contributors"`
|
Contributors []deezerArtist `json:"contributors"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type deezerArtist struct {
|
type deezerArtist struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Picture string `json:"picture"`
|
Picture string `json:"picture"`
|
||||||
PictureMedium string `json:"picture_medium"`
|
PictureMedium string `json:"picture_medium"`
|
||||||
PictureBig string `json:"picture_big"`
|
PictureBig string `json:"picture_big"`
|
||||||
PictureXL string `json:"picture_xl"`
|
PictureXL string `json:"picture_xl"`
|
||||||
NbFan int `json:"nb_fan"`
|
NbFan int `json:"nb_fan"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type deezerAlbumSimple struct {
|
type deezerAlbumSimple struct {
|
||||||
@@ -90,6 +90,7 @@ type deezerAlbumSimple struct {
|
|||||||
CoverXL string `json:"cover_xl"`
|
CoverXL string `json:"cover_xl"`
|
||||||
ReleaseDate string `json:"release_date"` // Sometimes at album level
|
ReleaseDate string `json:"release_date"` // Sometimes at album level
|
||||||
}
|
}
|
||||||
|
|
||||||
// ... (skip other structs as they are fine/unchanged) ...
|
// ... (skip other structs as they are fine/unchanged) ...
|
||||||
|
|
||||||
// ... (in convertTrack) ...
|
// ... (in convertTrack) ...
|
||||||
@@ -137,17 +138,17 @@ func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type deezerAlbumFull struct {
|
type deezerAlbumFull struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Cover string `json:"cover"`
|
Cover string `json:"cover"`
|
||||||
CoverMedium string `json:"cover_medium"`
|
CoverMedium string `json:"cover_medium"`
|
||||||
CoverBig string `json:"cover_big"`
|
CoverBig string `json:"cover_big"`
|
||||||
CoverXL string `json:"cover_xl"`
|
CoverXL string `json:"cover_xl"`
|
||||||
ReleaseDate string `json:"release_date"`
|
ReleaseDate string `json:"release_date"`
|
||||||
NbTracks int `json:"nb_tracks"`
|
NbTracks int `json:"nb_tracks"`
|
||||||
Artist deezerArtist `json:"artist"`
|
Artist deezerArtist `json:"artist"`
|
||||||
Contributors []deezerArtist `json:"contributors"`
|
Contributors []deezerArtist `json:"contributors"`
|
||||||
Tracks struct {
|
Tracks struct {
|
||||||
Data []deezerTrack `json:"data"`
|
Data []deezerTrack `json:"data"`
|
||||||
} `json:"tracks"`
|
} `json:"tracks"`
|
||||||
}
|
}
|
||||||
@@ -164,17 +165,17 @@ type deezerArtistFull struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type deezerPlaylistFull struct {
|
type deezerPlaylistFull struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Picture string `json:"picture"`
|
Picture string `json:"picture"`
|
||||||
PictureMedium string `json:"picture_medium"`
|
PictureMedium string `json:"picture_medium"`
|
||||||
PictureBig string `json:"picture_big"`
|
PictureBig string `json:"picture_big"`
|
||||||
PictureXL string `json:"picture_xl"`
|
PictureXL string `json:"picture_xl"`
|
||||||
NbTracks int `json:"nb_tracks"`
|
NbTracks int `json:"nb_tracks"`
|
||||||
Creator struct {
|
Creator struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
} `json:"creator"`
|
} `json:"creator"`
|
||||||
Tracks struct {
|
Tracks struct {
|
||||||
Data []deezerTrack `json:"data"`
|
Data []deezerTrack `json:"data"`
|
||||||
} `json:"tracks"`
|
} `json:"tracks"`
|
||||||
}
|
}
|
||||||
@@ -182,11 +183,14 @@ type deezerPlaylistFull struct {
|
|||||||
// SearchAll searches for tracks and artists on Deezer
|
// SearchAll searches for tracks and artists on Deezer
|
||||||
// NOTE: ISRC is NOT fetched during search for performance - use GetTrackISRC when needed for download
|
// 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) {
|
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)
|
||||||
|
|
||||||
cacheKey := fmt.Sprintf("deezer:all:%s:%d:%d", query, trackLimit, artistLimit)
|
cacheKey := fmt.Sprintf("deezer:all:%s:%d:%d", query, trackLimit, artistLimit)
|
||||||
|
|
||||||
c.cacheMu.RLock()
|
c.cacheMu.RLock()
|
||||||
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
|
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
|
||||||
c.cacheMu.RUnlock()
|
c.cacheMu.RUnlock()
|
||||||
|
GoLog("[Deezer] SearchAll: returning cached result\n")
|
||||||
return entry.data.(*SearchAllResult), nil
|
return entry.data.(*SearchAllResult), nil
|
||||||
}
|
}
|
||||||
c.cacheMu.RUnlock()
|
c.cacheMu.RUnlock()
|
||||||
@@ -198,13 +202,28 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
|
|||||||
|
|
||||||
// Search tracks - NO ISRC fetch for performance
|
// Search tracks - NO ISRC fetch for performance
|
||||||
trackURL := fmt.Sprintf("%s/track?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), trackLimit)
|
trackURL := fmt.Sprintf("%s/track?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), trackLimit)
|
||||||
|
GoLog("[Deezer] Fetching tracks from: %s\n", trackURL)
|
||||||
|
|
||||||
var trackResp struct {
|
var trackResp struct {
|
||||||
Data []deezerTrack `json:"data"`
|
Data []deezerTrack `json:"data"`
|
||||||
|
Error *struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Code int `json:"code"`
|
||||||
|
} `json:"error"`
|
||||||
}
|
}
|
||||||
if err := c.getJSON(ctx, trackURL, &trackResp); err != nil {
|
if err := c.getJSON(ctx, trackURL, &trackResp); err != nil {
|
||||||
|
GoLog("[Deezer] Track search failed: %v\n", err)
|
||||||
return nil, fmt.Errorf("deezer track search failed: %w", err)
|
return nil, fmt.Errorf("deezer track search failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if trackResp.Error != nil {
|
||||||
|
GoLog("[Deezer] API error: type=%s, code=%d, message=%s\n", trackResp.Error.Type, trackResp.Error.Code, trackResp.Error.Message)
|
||||||
|
return nil, fmt.Errorf("deezer API error: %s (code %d)", trackResp.Error.Message, trackResp.Error.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[Deezer] Got %d tracks from API\n", len(trackResp.Data))
|
||||||
|
|
||||||
for _, track := range trackResp.Data {
|
for _, track := range trackResp.Data {
|
||||||
// Convert directly without fetching ISRC - much faster
|
// Convert directly without fetching ISRC - much faster
|
||||||
result.Tracks = append(result.Tracks, c.convertTrack(track))
|
result.Tracks = append(result.Tracks, c.convertTrack(track))
|
||||||
@@ -212,21 +231,37 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
|
|||||||
|
|
||||||
// Search artists
|
// Search artists
|
||||||
artistURL := fmt.Sprintf("%s/artist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), artistLimit)
|
artistURL := fmt.Sprintf("%s/artist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), artistLimit)
|
||||||
|
GoLog("[Deezer] Fetching artists from: %s\n", artistURL)
|
||||||
|
|
||||||
var artistResp struct {
|
var artistResp struct {
|
||||||
Data []deezerArtist `json:"data"`
|
Data []deezerArtist `json:"data"`
|
||||||
|
Error *struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Code int `json:"code"`
|
||||||
|
} `json:"error"`
|
||||||
}
|
}
|
||||||
if err := c.getJSON(ctx, artistURL, &artistResp); err == nil {
|
if err := c.getJSON(ctx, artistURL, &artistResp); err == nil {
|
||||||
for _, artist := range artistResp.Data {
|
if artistResp.Error != nil {
|
||||||
result.Artists = append(result.Artists, SearchArtistResult{
|
GoLog("[Deezer] Artist API error: type=%s, code=%d, message=%s\n", artistResp.Error.Type, artistResp.Error.Code, artistResp.Error.Message)
|
||||||
ID: fmt.Sprintf("deezer:%d", artist.ID),
|
} else {
|
||||||
Name: artist.Name,
|
GoLog("[Deezer] Got %d artists from API\n", len(artistResp.Data))
|
||||||
Images: c.getBestArtistImage(artist),
|
for _, artist := range artistResp.Data {
|
||||||
Followers: artist.NbFan,
|
result.Artists = append(result.Artists, SearchArtistResult{
|
||||||
Popularity: 0,
|
ID: fmt.Sprintf("deezer:%d", artist.ID),
|
||||||
})
|
Name: artist.Name,
|
||||||
|
Images: c.getBestArtistImage(artist),
|
||||||
|
Followers: artist.NbFan,
|
||||||
|
Popularity: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
GoLog("[Deezer] Artist search failed: %v\n", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
GoLog("[Deezer] SearchAll complete: %d tracks, %d artists\n", len(result.Tracks), len(result.Artists))
|
||||||
|
|
||||||
// Cache result
|
// Cache result
|
||||||
c.cacheMu.Lock()
|
c.cacheMu.Lock()
|
||||||
c.searchCache[cacheKey] = &cacheEntry{
|
c.searchCache[cacheKey] = &cacheEntry{
|
||||||
@@ -603,8 +638,6 @@ func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string
|
|||||||
return fullTrack.ISRC, nil
|
return fullTrack.ISRC, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
func (c *DeezerClient) getBestArtistImage(artist deezerArtist) string {
|
func (c *DeezerClient) getBestArtistImage(artist deezerArtist) string {
|
||||||
if artist.PictureXL != "" {
|
if artist.PictureXL != "" {
|
||||||
return artist.PictureXL
|
return artist.PictureXL
|
||||||
|
|||||||
+111
-26
@@ -133,7 +133,7 @@ type DownloadRequest struct {
|
|||||||
DiscNumber int `json:"disc_number"`
|
DiscNumber int `json:"disc_number"`
|
||||||
TotalTracks int `json:"total_tracks"`
|
TotalTracks int `json:"total_tracks"`
|
||||||
ReleaseDate string `json:"release_date"`
|
ReleaseDate string `json:"release_date"`
|
||||||
ItemID string `json:"item_id"` // Unique ID for progress tracking
|
ItemID string `json:"item_id"` // Unique ID for progress tracking
|
||||||
DurationMS int `json:"duration_ms"` // Expected duration in milliseconds (for verification)
|
DurationMS int `json:"duration_ms"` // Expected duration in milliseconds (for verification)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,6 +155,7 @@ type DownloadResponse struct {
|
|||||||
ReleaseDate string `json:"release_date,omitempty"`
|
ReleaseDate string `json:"release_date,omitempty"`
|
||||||
TrackNumber int `json:"track_number,omitempty"`
|
TrackNumber int `json:"track_number,omitempty"`
|
||||||
DiscNumber int `json:"disc_number,omitempty"`
|
DiscNumber int `json:"disc_number,omitempty"`
|
||||||
|
ISRC string `json:"isrc,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DownloadResult is a generic result type for all downloaders
|
// DownloadResult is a generic result type for all downloaders
|
||||||
@@ -169,6 +170,7 @@ type DownloadResult struct {
|
|||||||
ReleaseDate string
|
ReleaseDate string
|
||||||
TrackNumber int
|
TrackNumber int
|
||||||
DiscNumber int
|
DiscNumber int
|
||||||
|
ISRC string
|
||||||
}
|
}
|
||||||
|
|
||||||
// DownloadTrack downloads a track from the specified service
|
// DownloadTrack downloads a track from the specified service
|
||||||
@@ -204,6 +206,7 @@ func DownloadTrack(requestJSON string) (string, error) {
|
|||||||
ReleaseDate: tidalResult.ReleaseDate,
|
ReleaseDate: tidalResult.ReleaseDate,
|
||||||
TrackNumber: tidalResult.TrackNumber,
|
TrackNumber: tidalResult.TrackNumber,
|
||||||
DiscNumber: tidalResult.DiscNumber,
|
DiscNumber: tidalResult.DiscNumber,
|
||||||
|
ISRC: tidalResult.ISRC,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
err = tidalErr
|
err = tidalErr
|
||||||
@@ -220,6 +223,7 @@ func DownloadTrack(requestJSON string) (string, error) {
|
|||||||
ReleaseDate: qobuzResult.ReleaseDate,
|
ReleaseDate: qobuzResult.ReleaseDate,
|
||||||
TrackNumber: qobuzResult.TrackNumber,
|
TrackNumber: qobuzResult.TrackNumber,
|
||||||
DiscNumber: qobuzResult.DiscNumber,
|
DiscNumber: qobuzResult.DiscNumber,
|
||||||
|
ISRC: qobuzResult.ISRC,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
err = qobuzErr
|
err = qobuzErr
|
||||||
@@ -236,6 +240,7 @@ func DownloadTrack(requestJSON string) (string, error) {
|
|||||||
ReleaseDate: amazonResult.ReleaseDate,
|
ReleaseDate: amazonResult.ReleaseDate,
|
||||||
TrackNumber: amazonResult.TrackNumber,
|
TrackNumber: amazonResult.TrackNumber,
|
||||||
DiscNumber: amazonResult.DiscNumber,
|
DiscNumber: amazonResult.DiscNumber,
|
||||||
|
ISRC: amazonResult.ISRC,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
err = amazonErr
|
err = amazonErr
|
||||||
@@ -264,6 +269,13 @@ func DownloadTrack(requestJSON string) (string, error) {
|
|||||||
ActualBitDepth: result.BitDepth,
|
ActualBitDepth: result.BitDepth,
|
||||||
ActualSampleRate: result.SampleRate,
|
ActualSampleRate: result.SampleRate,
|
||||||
Service: req.Service,
|
Service: req.Service,
|
||||||
|
Title: result.Title,
|
||||||
|
Artist: result.Artist,
|
||||||
|
Album: result.Album,
|
||||||
|
ReleaseDate: result.ReleaseDate,
|
||||||
|
TrackNumber: result.TrackNumber,
|
||||||
|
DiscNumber: result.DiscNumber,
|
||||||
|
ISRC: result.ISRC,
|
||||||
}
|
}
|
||||||
jsonBytes, _ := json.Marshal(resp)
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
@@ -274,9 +286,9 @@ func DownloadTrack(requestJSON string) (string, error) {
|
|||||||
if qErr == nil {
|
if qErr == nil {
|
||||||
result.BitDepth = quality.BitDepth
|
result.BitDepth = quality.BitDepth
|
||||||
result.SampleRate = quality.SampleRate
|
result.SampleRate = quality.SampleRate
|
||||||
fmt.Printf("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
GoLog("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("[Download] Could not read quality from file: %v\n", qErr)
|
GoLog("[Download] Could not read quality from file: %v\n", qErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp := DownloadResponse{
|
resp := DownloadResponse{
|
||||||
@@ -292,6 +304,7 @@ func DownloadTrack(requestJSON string) (string, error) {
|
|||||||
ReleaseDate: result.ReleaseDate,
|
ReleaseDate: result.ReleaseDate,
|
||||||
TrackNumber: result.TrackNumber,
|
TrackNumber: result.TrackNumber,
|
||||||
DiscNumber: result.DiscNumber,
|
DiscNumber: result.DiscNumber,
|
||||||
|
ISRC: result.ISRC,
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonBytes, _ := json.Marshal(resp)
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
@@ -314,13 +327,13 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
req.OutputDir = strings.TrimSpace(req.OutputDir)
|
req.OutputDir = strings.TrimSpace(req.OutputDir)
|
||||||
|
|
||||||
// Build service order starting with preferred service
|
// Build service order starting with preferred service
|
||||||
allServices := []string{"qobuz", "tidal", "amazon"}
|
allServices := []string{"tidal", "qobuz", "amazon"}
|
||||||
preferredService := req.Service
|
preferredService := req.Service
|
||||||
if preferredService == "" {
|
if preferredService == "" {
|
||||||
preferredService = "tidal"
|
preferredService = "tidal"
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("[DownloadWithFallback] Preferred service from request: '%s'\n", req.Service)
|
GoLog("[DownloadWithFallback] Preferred service from request: '%s'\n", req.Service)
|
||||||
|
|
||||||
// Create ordered list: preferred first, then others
|
// Create ordered list: preferred first, then others
|
||||||
services := []string{preferredService}
|
services := []string{preferredService}
|
||||||
@@ -330,12 +343,12 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("[DownloadWithFallback] Service order: %v\n", services)
|
GoLog("[DownloadWithFallback] Service order: %v\n", services)
|
||||||
|
|
||||||
var lastErr error
|
var lastErr error
|
||||||
|
|
||||||
for _, service := range services {
|
for _, service := range services {
|
||||||
fmt.Printf("[DownloadWithFallback] Trying service: %s\n", service)
|
GoLog("[DownloadWithFallback] Trying service: %s\n", service)
|
||||||
req.Service = service
|
req.Service = service
|
||||||
|
|
||||||
var result DownloadResult
|
var result DownloadResult
|
||||||
@@ -355,21 +368,29 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
ReleaseDate: tidalResult.ReleaseDate,
|
ReleaseDate: tidalResult.ReleaseDate,
|
||||||
TrackNumber: tidalResult.TrackNumber,
|
TrackNumber: tidalResult.TrackNumber,
|
||||||
DiscNumber: tidalResult.DiscNumber,
|
DiscNumber: tidalResult.DiscNumber,
|
||||||
|
ISRC: tidalResult.ISRC,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("[DownloadWithFallback] Tidal error: %v\n", tidalErr)
|
GoLog("[DownloadWithFallback] Tidal error: %v\n", tidalErr)
|
||||||
}
|
}
|
||||||
err = tidalErr
|
err = tidalErr
|
||||||
case "qobuz":
|
case "qobuz":
|
||||||
qobuzResult, qobuzErr := downloadFromQobuz(req)
|
qobuzResult, qobuzErr := downloadFromQobuz(req)
|
||||||
if qobuzErr == nil {
|
if qobuzErr == nil {
|
||||||
result = DownloadResult{
|
result = DownloadResult{
|
||||||
FilePath: qobuzResult.FilePath,
|
FilePath: qobuzResult.FilePath,
|
||||||
BitDepth: qobuzResult.BitDepth,
|
BitDepth: qobuzResult.BitDepth,
|
||||||
SampleRate: qobuzResult.SampleRate,
|
SampleRate: qobuzResult.SampleRate,
|
||||||
|
Title: qobuzResult.Title,
|
||||||
|
Artist: qobuzResult.Artist,
|
||||||
|
Album: qobuzResult.Album,
|
||||||
|
ReleaseDate: qobuzResult.ReleaseDate,
|
||||||
|
TrackNumber: qobuzResult.TrackNumber,
|
||||||
|
DiscNumber: qobuzResult.DiscNumber,
|
||||||
|
ISRC: qobuzResult.ISRC,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("[DownloadWithFallback] Qobuz error: %v\n", qobuzErr)
|
GoLog("[DownloadWithFallback] Qobuz error: %v\n", qobuzErr)
|
||||||
}
|
}
|
||||||
err = qobuzErr
|
err = qobuzErr
|
||||||
case "amazon":
|
case "amazon":
|
||||||
@@ -385,9 +406,10 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
ReleaseDate: amazonResult.ReleaseDate,
|
ReleaseDate: amazonResult.ReleaseDate,
|
||||||
TrackNumber: amazonResult.TrackNumber,
|
TrackNumber: amazonResult.TrackNumber,
|
||||||
DiscNumber: amazonResult.DiscNumber,
|
DiscNumber: amazonResult.DiscNumber,
|
||||||
|
ISRC: amazonResult.ISRC,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("[DownloadWithFallback] Amazon error: %v\n", amazonErr)
|
GoLog("[DownloadWithFallback] Amazon error: %v\n", amazonErr)
|
||||||
}
|
}
|
||||||
err = amazonErr
|
err = amazonErr
|
||||||
}
|
}
|
||||||
@@ -410,6 +432,13 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
ActualBitDepth: result.BitDepth,
|
ActualBitDepth: result.BitDepth,
|
||||||
ActualSampleRate: result.SampleRate,
|
ActualSampleRate: result.SampleRate,
|
||||||
Service: service,
|
Service: service,
|
||||||
|
Title: result.Title,
|
||||||
|
Artist: result.Artist,
|
||||||
|
Album: result.Album,
|
||||||
|
ReleaseDate: result.ReleaseDate,
|
||||||
|
TrackNumber: result.TrackNumber,
|
||||||
|
DiscNumber: result.DiscNumber,
|
||||||
|
ISRC: result.ISRC,
|
||||||
}
|
}
|
||||||
jsonBytes, _ := json.Marshal(resp)
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
@@ -420,9 +449,9 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
if qErr == nil {
|
if qErr == nil {
|
||||||
result.BitDepth = quality.BitDepth
|
result.BitDepth = quality.BitDepth
|
||||||
result.SampleRate = quality.SampleRate
|
result.SampleRate = quality.SampleRate
|
||||||
fmt.Printf("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
GoLog("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("[Download] Could not read quality from file: %v\n", qErr)
|
GoLog("[Download] Could not read quality from file: %v\n", qErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp := DownloadResponse{
|
resp := DownloadResponse{
|
||||||
@@ -432,6 +461,13 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
ActualBitDepth: result.BitDepth,
|
ActualBitDepth: result.BitDepth,
|
||||||
ActualSampleRate: result.SampleRate,
|
ActualSampleRate: result.SampleRate,
|
||||||
Service: service,
|
Service: service,
|
||||||
|
Title: result.Title,
|
||||||
|
Artist: result.Artist,
|
||||||
|
Album: result.Album,
|
||||||
|
ReleaseDate: result.ReleaseDate,
|
||||||
|
TrackNumber: result.TrackNumber,
|
||||||
|
DiscNumber: result.DiscNumber,
|
||||||
|
ISRC: result.ISRC,
|
||||||
}
|
}
|
||||||
jsonBytes, _ := json.Marshal(resp)
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
@@ -477,6 +513,51 @@ func CleanupConnections() {
|
|||||||
CloseIdleConnections()
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
result := map[string]interface{}{
|
||||||
|
"title": metadata.Title,
|
||||||
|
"artist": metadata.Artist,
|
||||||
|
"album": metadata.Album,
|
||||||
|
"album_artist": metadata.AlbumArtist,
|
||||||
|
"date": metadata.Date,
|
||||||
|
"track_number": metadata.TrackNumber,
|
||||||
|
"disc_number": metadata.DiscNumber,
|
||||||
|
"isrc": metadata.ISRC,
|
||||||
|
"lyrics": metadata.Lyrics,
|
||||||
|
"duration": duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add quality info if available
|
||||||
|
if qualityErr == nil {
|
||||||
|
result["bit_depth"] = quality.BitDepth
|
||||||
|
result["sample_rate"] = quality.SampleRate
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBytes, err := json.Marshal(result)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
// SetDownloadDirectory sets the default download directory
|
// SetDownloadDirectory sets the default download directory
|
||||||
func SetDownloadDirectory(path string) error {
|
func SetDownloadDirectory(path string) error {
|
||||||
return setDownloadDir(path)
|
return setDownloadDir(path)
|
||||||
@@ -830,7 +911,7 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
|
|||||||
return "", fmt.Errorf("spotify rate limited and failed to parse URL: %w", parseErr)
|
return "", fmt.Errorf("spotify rate limited and failed to parse URL: %w", parseErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("[Fallback] Spotify rate limited for %s, trying Deezer...\n", parsed.Type)
|
GoLog("[Fallback] Spotify rate limited for %s, trying Deezer...\n", parsed.Type)
|
||||||
|
|
||||||
if parsed.Type == "track" || parsed.Type == "album" {
|
if parsed.Type == "track" || parsed.Type == "album" {
|
||||||
// Convert to Deezer
|
// Convert to Deezer
|
||||||
@@ -906,20 +987,24 @@ func errorResponse(msg string) (string, error) {
|
|||||||
errorType := "unknown"
|
errorType := "unknown"
|
||||||
lowerMsg := strings.ToLower(msg)
|
lowerMsg := strings.ToLower(msg)
|
||||||
|
|
||||||
if strings.Contains(lowerMsg, "not found") ||
|
if strings.Contains(lowerMsg, "isp blocking") ||
|
||||||
strings.Contains(lowerMsg, "not available") ||
|
strings.Contains(lowerMsg, "try using vpn") ||
|
||||||
strings.Contains(lowerMsg, "no results") ||
|
strings.Contains(lowerMsg, "change dns") {
|
||||||
strings.Contains(lowerMsg, "track not found") ||
|
errorType = "isp_blocked"
|
||||||
strings.Contains(lowerMsg, "all services failed") {
|
} else if strings.Contains(lowerMsg, "not found") ||
|
||||||
|
strings.Contains(lowerMsg, "not available") ||
|
||||||
|
strings.Contains(lowerMsg, "no results") ||
|
||||||
|
strings.Contains(lowerMsg, "track not found") ||
|
||||||
|
strings.Contains(lowerMsg, "all services failed") {
|
||||||
errorType = "not_found"
|
errorType = "not_found"
|
||||||
} else if strings.Contains(lowerMsg, "rate limit") ||
|
} else if strings.Contains(lowerMsg, "rate limit") ||
|
||||||
strings.Contains(lowerMsg, "429") ||
|
strings.Contains(lowerMsg, "429") ||
|
||||||
strings.Contains(lowerMsg, "too many requests") {
|
strings.Contains(lowerMsg, "too many requests") {
|
||||||
errorType = "rate_limit"
|
errorType = "rate_limit"
|
||||||
} else if strings.Contains(lowerMsg, "network") ||
|
} else if strings.Contains(lowerMsg, "network") ||
|
||||||
strings.Contains(lowerMsg, "connection") ||
|
strings.Contains(lowerMsg, "connection") ||
|
||||||
strings.Contains(lowerMsg, "timeout") ||
|
strings.Contains(lowerMsg, "timeout") ||
|
||||||
strings.Contains(lowerMsg, "dial") {
|
strings.Contains(lowerMsg, "dial") {
|
||||||
errorType = "network"
|
errorType = "network"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+218
-1
@@ -1,12 +1,17 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -134,9 +139,15 @@ func CloseIdleConnections() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DoRequestWithUserAgent executes an HTTP request with a random User-Agent header
|
// 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) {
|
func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Response, error) {
|
||||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
return client.Do(req)
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
// Check for ISP blocking
|
||||||
|
CheckAndLogISPBlocking(err, req.URL.String(), "HTTP")
|
||||||
|
}
|
||||||
|
return resp, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// RetryConfig holds configuration for retry logic
|
// RetryConfig holds configuration for retry logic
|
||||||
@@ -159,9 +170,11 @@ func DefaultRetryConfig() RetryConfig {
|
|||||||
|
|
||||||
// DoRequestWithRetry executes an HTTP request with retry logic and exponential backoff
|
// DoRequestWithRetry executes an HTTP request with retry logic and exponential backoff
|
||||||
// Handles 429 (Too Many Requests) responses with Retry-After header
|
// Handles 429 (Too Many Requests) responses with Retry-After header
|
||||||
|
// Also detects and logs ISP blocking
|
||||||
func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConfig) (*http.Response, error) {
|
func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConfig) (*http.Response, error) {
|
||||||
var lastErr error
|
var lastErr error
|
||||||
delay := config.InitialDelay
|
delay := config.InitialDelay
|
||||||
|
requestURL := req.URL.String()
|
||||||
|
|
||||||
for attempt := 0; attempt <= config.MaxRetries; attempt++ {
|
for attempt := 0; attempt <= config.MaxRetries; attempt++ {
|
||||||
// Clone request for retry (body needs to be re-readable)
|
// Clone request for retry (body needs to be re-readable)
|
||||||
@@ -171,7 +184,16 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
|||||||
resp, err := client.Do(reqCopy)
|
resp, err := client.Do(reqCopy)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
lastErr = err
|
lastErr = err
|
||||||
|
|
||||||
|
// Check for ISP blocking on network errors
|
||||||
|
if CheckAndLogISPBlocking(err, requestURL, "HTTP") {
|
||||||
|
// Don't retry if ISP blocking is detected - it won't help
|
||||||
|
return nil, WrapErrorWithISPCheck(err, requestURL, "HTTP")
|
||||||
|
}
|
||||||
|
|
||||||
if attempt < config.MaxRetries {
|
if attempt < config.MaxRetries {
|
||||||
|
GoLog("[HTTP] Request failed (attempt %d/%d): %v, retrying in %v...\n",
|
||||||
|
attempt+1, config.MaxRetries+1, err, delay)
|
||||||
time.Sleep(delay)
|
time.Sleep(delay)
|
||||||
delay = calculateNextDelay(delay, config)
|
delay = calculateNextDelay(delay, config)
|
||||||
}
|
}
|
||||||
@@ -192,17 +214,43 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
|||||||
}
|
}
|
||||||
lastErr = fmt.Errorf("rate limited (429)")
|
lastErr = fmt.Errorf("rate limited (429)")
|
||||||
if attempt < config.MaxRetries {
|
if attempt < config.MaxRetries {
|
||||||
|
GoLog("[HTTP] Rate limited, waiting %v before retry...\n", delay)
|
||||||
time.Sleep(delay)
|
time.Sleep(delay)
|
||||||
delay = calculateNextDelay(delay, config)
|
delay = calculateNextDelay(delay, config)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for ISP blocking via HTTP status codes
|
||||||
|
// Some ISPs return 403 or 451 when blocking content
|
||||||
|
if resp.StatusCode == 403 || resp.StatusCode == 451 {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
bodyStr := strings.ToLower(string(body))
|
||||||
|
|
||||||
|
// Check if response looks like ISP blocking page
|
||||||
|
ispBlockingIndicators := []string{
|
||||||
|
"blocked", "forbidden", "access denied", "not available in your",
|
||||||
|
"restricted", "censored", "unavailable for legal", "blocked by",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, indicator := range ispBlockingIndicators {
|
||||||
|
if strings.Contains(bodyStr, indicator) {
|
||||||
|
LogError("HTTP", "ISP BLOCKING DETECTED via HTTP %d response", resp.StatusCode)
|
||||||
|
LogError("HTTP", "Domain: %s", req.URL.Host)
|
||||||
|
LogError("HTTP", "Response contains: %s", indicator)
|
||||||
|
LogError("HTTP", "Suggestion: Try using a VPN or changing your DNS to 1.1.1.1 or 8.8.8.8")
|
||||||
|
return nil, fmt.Errorf("ISP blocking detected for %s (HTTP %d) - try using VPN or change DNS", req.URL.Host, resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Server errors (5xx) - retry
|
// Server errors (5xx) - retry
|
||||||
if resp.StatusCode >= 500 {
|
if resp.StatusCode >= 500 {
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
lastErr = fmt.Errorf("server error: HTTP %d", resp.StatusCode)
|
lastErr = fmt.Errorf("server error: HTTP %d", resp.StatusCode)
|
||||||
if attempt < config.MaxRetries {
|
if attempt < config.MaxRetries {
|
||||||
|
GoLog("[HTTP] Server error %d, retrying in %v...\n", resp.StatusCode, delay)
|
||||||
time.Sleep(delay)
|
time.Sleep(delay)
|
||||||
delay = calculateNextDelay(delay, config)
|
delay = calculateNextDelay(delay, config)
|
||||||
}
|
}
|
||||||
@@ -296,3 +344,172 @@ func BuildErrorMessage(apiURL string, statusCode int, responsePreview string) st
|
|||||||
}
|
}
|
||||||
return msg
|
return msg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ISPBlockingError represents an error caused by ISP blocking
|
||||||
|
type ISPBlockingError struct {
|
||||||
|
Domain string
|
||||||
|
Reason string
|
||||||
|
OriginalErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ISPBlockingError) Error() string {
|
||||||
|
return fmt.Sprintf("ISP blocking detected for %s: %s", e.Domain, e.Reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsISPBlocking checks if an error is likely caused by ISP blocking
|
||||||
|
// Returns the ISPBlockingError if detected, nil otherwise
|
||||||
|
func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract domain from URL
|
||||||
|
domain := extractDomain(requestURL)
|
||||||
|
errStr := strings.ToLower(err.Error())
|
||||||
|
|
||||||
|
// Check for DNS resolution failure (common ISP blocking method)
|
||||||
|
var dnsErr *net.DNSError
|
||||||
|
if errors.As(err, &dnsErr) {
|
||||||
|
if dnsErr.IsNotFound || dnsErr.IsTemporary {
|
||||||
|
return &ISPBlockingError{
|
||||||
|
Domain: domain,
|
||||||
|
Reason: "DNS resolution failed - domain may be blocked by ISP",
|
||||||
|
OriginalErr: err,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for connection refused (ISP firewall blocking)
|
||||||
|
var opErr *net.OpError
|
||||||
|
if errors.As(err, &opErr) {
|
||||||
|
if opErr.Op == "dial" {
|
||||||
|
// Check for specific syscall errors
|
||||||
|
var syscallErr syscall.Errno
|
||||||
|
if errors.As(opErr.Err, &syscallErr) {
|
||||||
|
switch syscallErr {
|
||||||
|
case syscall.ECONNREFUSED:
|
||||||
|
return &ISPBlockingError{
|
||||||
|
Domain: domain,
|
||||||
|
Reason: "Connection refused - port may be blocked by ISP/firewall",
|
||||||
|
OriginalErr: err,
|
||||||
|
}
|
||||||
|
case syscall.ECONNRESET:
|
||||||
|
return &ISPBlockingError{
|
||||||
|
Domain: domain,
|
||||||
|
Reason: "Connection reset - ISP may be intercepting traffic",
|
||||||
|
OriginalErr: err,
|
||||||
|
}
|
||||||
|
case syscall.ETIMEDOUT:
|
||||||
|
return &ISPBlockingError{
|
||||||
|
Domain: domain,
|
||||||
|
Reason: "Connection timed out - ISP may be blocking access",
|
||||||
|
OriginalErr: err,
|
||||||
|
}
|
||||||
|
case syscall.ENETUNREACH:
|
||||||
|
return &ISPBlockingError{
|
||||||
|
Domain: domain,
|
||||||
|
Reason: "Network unreachable - ISP may be blocking route",
|
||||||
|
OriginalErr: err,
|
||||||
|
}
|
||||||
|
case syscall.EHOSTUNREACH:
|
||||||
|
return &ISPBlockingError{
|
||||||
|
Domain: domain,
|
||||||
|
Reason: "Host unreachable - ISP may be blocking destination",
|
||||||
|
OriginalErr: err,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for TLS handshake failure (ISP MITM or blocking HTTPS)
|
||||||
|
var tlsErr *tls.RecordHeaderError
|
||||||
|
if errors.As(err, &tlsErr) {
|
||||||
|
return &ISPBlockingError{
|
||||||
|
Domain: domain,
|
||||||
|
Reason: "TLS handshake failed - ISP may be intercepting HTTPS traffic",
|
||||||
|
OriginalErr: err,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check error message patterns for common ISP blocking indicators
|
||||||
|
blockingPatterns := []struct {
|
||||||
|
pattern string
|
||||||
|
reason string
|
||||||
|
}{
|
||||||
|
{"connection reset by peer", "Connection reset - ISP may be intercepting traffic"},
|
||||||
|
{"connection refused", "Connection refused - port may be blocked"},
|
||||||
|
{"no such host", "DNS lookup failed - domain may be blocked by ISP"},
|
||||||
|
{"i/o timeout", "Connection timed out - ISP may be blocking access"},
|
||||||
|
{"network is unreachable", "Network unreachable - ISP may be blocking route"},
|
||||||
|
{"tls: ", "TLS error - ISP may be intercepting HTTPS traffic"},
|
||||||
|
{"certificate", "Certificate error - ISP may be using MITM proxy"},
|
||||||
|
{"eof", "Connection closed unexpectedly - ISP may be blocking"},
|
||||||
|
{"context deadline exceeded", "Request timed out - ISP may be throttling"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, bp := range blockingPatterns {
|
||||||
|
if strings.Contains(errStr, bp.pattern) {
|
||||||
|
return &ISPBlockingError{
|
||||||
|
Domain: domain,
|
||||||
|
Reason: bp.reason,
|
||||||
|
OriginalErr: err,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
if ispErr != nil {
|
||||||
|
LogError(tag, "ISP BLOCKING DETECTED: %s", ispErr.Error())
|
||||||
|
LogError(tag, "Domain: %s", ispErr.Domain)
|
||||||
|
LogError(tag, "Reason: %s", ispErr.Reason)
|
||||||
|
LogError(tag, "Original error: %v", ispErr.OriginalErr)
|
||||||
|
LogError(tag, "Suggestion: Try using a VPN or changing your DNS to 1.1.1.1 or 8.8.8.8")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractDomain extracts the domain from a URL string
|
||||||
|
func extractDomain(rawURL string) string {
|
||||||
|
if rawURL == "" {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := url.Parse(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
// Try to extract domain manually
|
||||||
|
rawURL = strings.TrimPrefix(rawURL, "https://")
|
||||||
|
rawURL = strings.TrimPrefix(rawURL, "http://")
|
||||||
|
if idx := strings.Index(rawURL, "/"); idx > 0 {
|
||||||
|
return rawURL[:idx]
|
||||||
|
}
|
||||||
|
return rawURL
|
||||||
|
}
|
||||||
|
|
||||||
|
if parsed.Host != "" {
|
||||||
|
return parsed.Host
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if CheckAndLogISPBlocking(err, requestURL, tag) {
|
||||||
|
domain := extractDomain(requestURL)
|
||||||
|
return fmt.Errorf("ISP blocking detected for %s - try using VPN or change DNS to 1.1.1.1/8.8.8.8: %w", domain, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LogEntry represents a single log entry
|
||||||
|
type LogEntry struct {
|
||||||
|
Timestamp string `json:"timestamp"`
|
||||||
|
Level string `json:"level"`
|
||||||
|
Tag string `json:"tag"`
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
globalLogBuffer *LogBuffer
|
||||||
|
logBufferOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetLogBuffer returns the singleton log buffer instance
|
||||||
|
func GetLogBuffer() *LogBuffer {
|
||||||
|
logBufferOnce.Do(func() {
|
||||||
|
globalLogBuffer = &LogBuffer{
|
||||||
|
entries: make([]LogEntry, 0, 500),
|
||||||
|
maxSize: 500,
|
||||||
|
loggingEnabled: false, // Default: disabled for performance (user can enable in settings)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return globalLogBuffer
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLoggingEnabled enables or disables logging
|
||||||
|
func (lb *LogBuffer) SetLoggingEnabled(enabled bool) {
|
||||||
|
lb.mu.Lock()
|
||||||
|
defer lb.mu.Unlock()
|
||||||
|
lb.loggingEnabled = enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsLoggingEnabled returns whether logging is enabled
|
||||||
|
func (lb *LogBuffer) IsLoggingEnabled() bool {
|
||||||
|
lb.mu.RLock()
|
||||||
|
defer lb.mu.RUnlock()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := LogEntry{
|
||||||
|
Timestamp: time.Now().Format("15:04:05.000"),
|
||||||
|
Level: level,
|
||||||
|
Tag: tag,
|
||||||
|
Message: message,
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAll returns all log entries as JSON
|
||||||
|
func (lb *LogBuffer) GetAll() string {
|
||||||
|
lb.mu.RLock()
|
||||||
|
defer lb.mu.RUnlock()
|
||||||
|
|
||||||
|
jsonBytes, _ := json.Marshal(lb.entries)
|
||||||
|
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()
|
||||||
|
|
||||||
|
if index < 0 {
|
||||||
|
index = 0
|
||||||
|
}
|
||||||
|
if index >= len(lb.entries) {
|
||||||
|
return []LogEntry{}, len(lb.entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries := lb.entries[index:]
|
||||||
|
return entries, len(lb.entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear clears all log entries
|
||||||
|
func (lb *LogBuffer) Clear() {
|
||||||
|
lb.mu.Lock()
|
||||||
|
defer lb.mu.Unlock()
|
||||||
|
lb.entries = lb.entries[:0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count returns the number of log entries
|
||||||
|
func (lb *LogBuffer) Count() int {
|
||||||
|
lb.mu.RLock()
|
||||||
|
defer lb.mu.RUnlock()
|
||||||
|
return len(lb.entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions for logging with different levels
|
||||||
|
func LogDebug(tag, format string, args ...interface{}) {
|
||||||
|
GetLogBuffer().Add("DEBUG", tag, fmt.Sprintf(format, args...))
|
||||||
|
}
|
||||||
|
|
||||||
|
func LogInfo(tag, format string, args ...interface{}) {
|
||||||
|
GetLogBuffer().Add("INFO", tag, fmt.Sprintf(format, args...))
|
||||||
|
}
|
||||||
|
|
||||||
|
func LogWarn(tag, format string, args ...interface{}) {
|
||||||
|
GetLogBuffer().Add("WARN", tag, fmt.Sprintf(format, args...))
|
||||||
|
}
|
||||||
|
|
||||||
|
func LogError(tag, format string, args ...interface{}) {
|
||||||
|
GetLogBuffer().Add("ERROR", tag, fmt.Sprintf(format, args...))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GoLog is a drop-in replacement for fmt.Printf that also logs to buffer
|
||||||
|
// It parses the tag from the format string if it starts with [Tag]
|
||||||
|
func GoLog(format string, args ...interface{}) {
|
||||||
|
message := fmt.Sprintf(format, args...)
|
||||||
|
message = strings.TrimSuffix(message, "\n")
|
||||||
|
|
||||||
|
// Extract tag from message if present (e.g., "[Tidal] message")
|
||||||
|
tag := "Go"
|
||||||
|
level := "INFO"
|
||||||
|
|
||||||
|
if strings.HasPrefix(message, "[") {
|
||||||
|
endBracket := strings.Index(message, "]")
|
||||||
|
if endBracket > 1 {
|
||||||
|
tag = message[1:endBracket]
|
||||||
|
message = strings.TrimSpace(message[endBracket+1:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine level from message content
|
||||||
|
msgLower := strings.ToLower(message)
|
||||||
|
if strings.Contains(msgLower, "error") || strings.Contains(msgLower, "failed") || strings.HasPrefix(message, "✗") {
|
||||||
|
level = "ERROR"
|
||||||
|
} else if strings.Contains(msgLower, "warning") || strings.Contains(msgLower, "warn") {
|
||||||
|
level = "WARN"
|
||||||
|
} else if strings.HasPrefix(message, "✓") || strings.Contains(msgLower, "success") || strings.Contains(msgLower, "match found") {
|
||||||
|
level = "INFO"
|
||||||
|
} else if strings.Contains(msgLower, "searching") || strings.Contains(msgLower, "trying") || strings.Contains(msgLower, "found") {
|
||||||
|
level = "DEBUG"
|
||||||
|
}
|
||||||
|
|
||||||
|
GetLogBuffer().Add(level, tag, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exported functions for Flutter
|
||||||
|
|
||||||
|
// GetLogs returns all logs as JSON array
|
||||||
|
func GetLogs() string {
|
||||||
|
return GetLogBuffer().GetAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLogsSince returns logs since the given index
|
||||||
|
// Returns JSON: {"logs": [...], "next_index": N}
|
||||||
|
func GetLogsSince(index int) string {
|
||||||
|
entries, nextIndex := GetLogBuffer().getSince(index)
|
||||||
|
logsJson, _ := json.Marshal(entries)
|
||||||
|
result := fmt.Sprintf(`{"logs":%s,"next_index":%d}`, string(logsJson), nextIndex)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearLogs clears all logs
|
||||||
|
func ClearLogs() {
|
||||||
|
GetLogBuffer().Clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLogCount returns the number of log entries
|
||||||
|
func GetLogCount() int {
|
||||||
|
return GetLogBuffer().Count()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLoggingEnabled enables or disables logging from Flutter
|
||||||
|
func SetLoggingEnabled(enabled bool) {
|
||||||
|
GetLogBuffer().SetLoggingEnabled(enabled)
|
||||||
|
}
|
||||||
+39
-7
@@ -257,11 +257,30 @@ func ReadMetadata(filePath string) (*Metadata, error) {
|
|||||||
if trackNum != "" {
|
if trackNum != "" {
|
||||||
fmt.Sscanf(trackNum, "%d", &metadata.TrackNumber)
|
fmt.Sscanf(trackNum, "%d", &metadata.TrackNumber)
|
||||||
}
|
}
|
||||||
|
// Also try lowercase variant (some encoders use lowercase)
|
||||||
|
if metadata.TrackNumber == 0 {
|
||||||
|
trackNum = getComment(cmt, "TRACK")
|
||||||
|
if trackNum != "" {
|
||||||
|
fmt.Sscanf(trackNum, "%d", &metadata.TrackNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
discNum := getComment(cmt, "DISCNUMBER")
|
discNum := getComment(cmt, "DISCNUMBER")
|
||||||
if discNum != "" {
|
if discNum != "" {
|
||||||
fmt.Sscanf(discNum, "%d", &metadata.DiscNumber)
|
fmt.Sscanf(discNum, "%d", &metadata.DiscNumber)
|
||||||
}
|
}
|
||||||
|
// Also try DISC variant
|
||||||
|
if metadata.DiscNumber == 0 {
|
||||||
|
discNum = getComment(cmt, "DISC")
|
||||||
|
if discNum != "" {
|
||||||
|
fmt.Sscanf(discNum, "%d", &metadata.DiscNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try DATE variants
|
||||||
|
if metadata.Date == "" {
|
||||||
|
metadata.Date = getComment(cmt, "YEAR")
|
||||||
|
}
|
||||||
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -291,9 +310,14 @@ func setComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) string {
|
func getComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) string {
|
||||||
|
keyUpper := strings.ToUpper(key) + "="
|
||||||
for _, comment := range cmt.Comments {
|
for _, comment := range cmt.Comments {
|
||||||
if len(comment) > len(key)+1 && comment[:len(key)+1] == key+"=" {
|
if len(comment) > len(key) {
|
||||||
return comment[len(key)+1:]
|
// Case-insensitive comparison for Vorbis comments
|
||||||
|
commentUpper := strings.ToUpper(comment[:len(key)+1])
|
||||||
|
if commentUpper == keyUpper {
|
||||||
|
return comment[len(key)+1:]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
@@ -376,8 +400,9 @@ func ExtractLyrics(filePath string) (string, error) {
|
|||||||
|
|
||||||
// AudioQuality represents audio quality info from a FLAC file
|
// AudioQuality represents audio quality info from a FLAC file
|
||||||
type AudioQuality struct {
|
type AudioQuality struct {
|
||||||
BitDepth int `json:"bit_depth"`
|
BitDepth int `json:"bit_depth"`
|
||||||
SampleRate int `json:"sample_rate"`
|
SampleRate int `json:"sample_rate"`
|
||||||
|
TotalSamples int64 `json:"total_samples"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAudioQuality reads bit depth and sample rate from a FLAC file's StreamInfo block
|
// GetAudioQuality reads bit depth and sample rate from a FLAC file's StreamInfo block
|
||||||
@@ -422,9 +447,17 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
|
|||||||
// Parse bits per sample (5 bits)
|
// Parse bits per sample (5 bits)
|
||||||
bitsPerSample := ((int(streamInfo[12]) & 0x01) << 4) | (int(streamInfo[13]) >> 4) + 1
|
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 |
|
||||||
|
int64(streamInfo[16])<<8 |
|
||||||
|
int64(streamInfo[17])
|
||||||
|
|
||||||
return AudioQuality{
|
return AudioQuality{
|
||||||
BitDepth: bitsPerSample,
|
BitDepth: bitsPerSample,
|
||||||
SampleRate: sampleRate,
|
SampleRate: sampleRate,
|
||||||
|
TotalSamples: totalSamples,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -445,7 +478,6 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
|
|||||||
return AudioQuality{}, fmt.Errorf("unsupported file format (not FLAC or M4A)")
|
return AudioQuality{}, fmt.Errorf("unsupported file format (not FLAC or M4A)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// M4A (MP4/AAC) Metadata Embedding
|
// M4A (MP4/AAC) Metadata Embedding
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|||||||
+273
-45
@@ -83,18 +83,193 @@ func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean),
|
// If scripts are TRULY different (Latin vs CJK/Arabic/Cyrillic), assume match (transliteration)
|
||||||
// assume they're the same artist with different transliteration
|
// Don't treat Latin Extended (Polish, French, etc.) as different script
|
||||||
expectedASCII := qobuzIsASCIIString(expectedArtist)
|
expectedLatin := qobuzIsLatinScript(expectedArtist)
|
||||||
foundASCII := qobuzIsASCIIString(foundArtist)
|
foundLatin := qobuzIsLatinScript(foundArtist)
|
||||||
if expectedASCII != foundASCII {
|
if expectedLatin != foundLatin {
|
||||||
fmt.Printf("[Qobuz] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist)
|
GoLog("[Qobuz] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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))
|
||||||
|
|
||||||
|
// Exact match
|
||||||
|
if normExpected == normFound {
|
||||||
|
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)
|
||||||
|
|
||||||
|
if cleanExpected == cleanFound {
|
||||||
|
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)
|
||||||
|
|
||||||
|
if coreExpected != "" && coreFound != "" && coreExpected == coreFound {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(expectedTitle)
|
||||||
|
foundLatin := qobuzIsLatinScript(foundTitle)
|
||||||
|
if expectedLatin != foundLatin {
|
||||||
|
GoLog("[Qobuz] Titles in different scripts, assuming match: '%s' vs '%s'\n", expectedTitle, foundTitle)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// qobuzExtractCoreTitle extracts the main title before any parentheses or brackets
|
||||||
|
func qobuzExtractCoreTitle(title string) string {
|
||||||
|
// Find first occurrence of ( or [
|
||||||
|
parenIdx := strings.Index(title, "(")
|
||||||
|
bracketIdx := strings.Index(title, "[")
|
||||||
|
dashIdx := strings.Index(title, " - ")
|
||||||
|
|
||||||
|
cutIdx := len(title)
|
||||||
|
if parenIdx > 0 && parenIdx < cutIdx {
|
||||||
|
cutIdx = parenIdx
|
||||||
|
}
|
||||||
|
if bracketIdx > 0 && bracketIdx < cutIdx {
|
||||||
|
cutIdx = bracketIdx
|
||||||
|
}
|
||||||
|
if dashIdx > 0 && dashIdx < cutIdx {
|
||||||
|
cutIdx = dashIdx
|
||||||
|
}
|
||||||
|
|
||||||
|
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, ")")
|
||||||
|
if startParen >= 0 && endParen > startParen {
|
||||||
|
content := strings.ToLower(cleaned[startParen+1 : endParen])
|
||||||
|
isVersionIndicator := false
|
||||||
|
for _, pattern := range versionPatterns {
|
||||||
|
if strings.Contains(content, pattern) {
|
||||||
|
isVersionIndicator = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isVersionIndicator {
|
||||||
|
cleaned = strings.TrimSpace(cleaned[:startParen]) + cleaned[endParen+1:]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same for brackets
|
||||||
|
for {
|
||||||
|
startBracket := strings.LastIndex(cleaned, "[")
|
||||||
|
endBracket := strings.LastIndex(cleaned, "]")
|
||||||
|
if startBracket >= 0 && endBracket > startBracket {
|
||||||
|
content := strings.ToLower(cleaned[startBracket+1 : endBracket])
|
||||||
|
isVersionIndicator := false
|
||||||
|
for _, pattern := range versionPatterns {
|
||||||
|
if strings.Contains(content, pattern) {
|
||||||
|
isVersionIndicator = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isVersionIndicator {
|
||||||
|
cleaned = strings.TrimSpace(cleaned[:startBracket]) + cleaned[endBracket+1:]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove trailing " - version" patterns
|
||||||
|
dashPatterns := []string{
|
||||||
|
" - remaster", " - remastered", " - single version", " - radio edit",
|
||||||
|
" - live", " - acoustic", " - demo", " - remix",
|
||||||
|
}
|
||||||
|
for _, pattern := range dashPatterns {
|
||||||
|
if strings.HasSuffix(strings.ToLower(cleaned), pattern) {
|
||||||
|
cleaned = cleaned[:len(cleaned)-len(pattern)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove multiple spaces
|
||||||
|
for strings.Contains(cleaned, " ") {
|
||||||
|
cleaned = strings.ReplaceAll(cleaned, " ", " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(cleaned)
|
||||||
|
}
|
||||||
|
|
||||||
|
// qobuzIsLatinScript checks if a string is primarily Latin script
|
||||||
|
// Returns true for ASCII and Latin Extended characters (European languages)
|
||||||
|
// Returns false for CJK, Arabic, Cyrillic, etc.
|
||||||
|
func qobuzIsLatinScript(s string) bool {
|
||||||
|
for _, r := range s {
|
||||||
|
// Skip common punctuation and numbers
|
||||||
|
if r < 128 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Latin Extended-A: U+0100 to U+017F (Polish, Czech, etc.)
|
||||||
|
// Latin Extended-B: U+0180 to U+024F
|
||||||
|
// Latin Extended Additional: U+1E00 to U+1EFF
|
||||||
|
// Latin Extended-C/D/E: various ranges
|
||||||
|
if (r >= 0x0100 && r <= 0x024F) || // Latin Extended A & B
|
||||||
|
(r >= 0x1E00 && r <= 0x1EFF) || // Latin Extended Additional
|
||||||
|
(r >= 0x00C0 && r <= 0x00FF) { // Latin-1 Supplement (accented chars)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// CJK ranges - definitely different script
|
||||||
|
if (r >= 0x4E00 && r <= 0x9FFF) || // CJK Unified Ideographs
|
||||||
|
(r >= 0x3040 && r <= 0x309F) || // Hiragana
|
||||||
|
(r >= 0x30A0 && r <= 0x30FF) || // Katakana
|
||||||
|
(r >= 0xAC00 && r <= 0xD7AF) || // Hangul (Korean)
|
||||||
|
(r >= 0x0600 && r <= 0x06FF) || // Arabic
|
||||||
|
(r >= 0x0400 && r <= 0x04FF) { // Cyrillic
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// qobuzIsASCIIString checks if a string contains only ASCII characters
|
// qobuzIsASCIIString checks if a string contains only ASCII characters
|
||||||
func qobuzIsASCIIString(s string) bool {
|
func qobuzIsASCIIString(s string) bool {
|
||||||
for _, r := range s {
|
for _, r := range s {
|
||||||
@@ -132,8 +307,8 @@ func (q *QobuzDownloader) GetAvailableAPIs() []string {
|
|||||||
// Same APIs as PC version (referensi/backend/qobuz.go)
|
// Same APIs as PC version (referensi/backend/qobuz.go)
|
||||||
// Primary: dab.yeet.su, Fallback: dabmusic.xyz
|
// Primary: dab.yeet.su, Fallback: dabmusic.xyz
|
||||||
encodedAPIs := []string{
|
encodedAPIs := []string{
|
||||||
"ZGFiLnllZXQuc3UvYXBpL3N0cmVhbT90cmFja0lkPQ==", // dab.yeet.su/api/stream?trackId= (PRIMARY - same as PC)
|
"ZGFiLnllZXQuc3UvYXBpL3N0cmVhbT90cmFja0lkPQ==", // dab.yeet.su/api/stream?trackId= (PRIMARY - same as PC)
|
||||||
"ZGFibXVzaWMueHl6L2FwaS9zdHJlYW0/dHJhY2tJZD0=", // dabmusic.xyz/api/stream?trackId= (FALLBACK - same as PC)
|
"ZGFibXVzaWMueHl6L2FwaS9zdHJlYW0/dHJhY2tJZD0=", // dabmusic.xyz/api/stream?trackId= (FALLBACK - same as PC)
|
||||||
}
|
}
|
||||||
|
|
||||||
var apis []string
|
var apis []string
|
||||||
@@ -194,6 +369,8 @@ func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) {
|
|||||||
// SearchTrackByISRCWithTitle searches for a track by ISRC with duration verification
|
// SearchTrackByISRCWithTitle searches for a track by ISRC with duration verification
|
||||||
// expectedDurationSec is the expected duration in seconds (0 to skip verification)
|
// expectedDurationSec is the expected duration in seconds (0 to skip verification)
|
||||||
func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDurationSec int) (*QobuzTrack, error) {
|
func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDurationSec int) (*QobuzTrack, error) {
|
||||||
|
GoLog("[Qobuz] Searching by ISRC: %s\n", isrc)
|
||||||
|
|
||||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
||||||
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(isrc), q.appID)
|
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(isrc), q.appID)
|
||||||
|
|
||||||
@@ -221,6 +398,8 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
GoLog("[Qobuz] ISRC search returned %d results\n", len(result.Tracks.Items))
|
||||||
|
|
||||||
// Find ISRC matches
|
// Find ISRC matches
|
||||||
var isrcMatches []*QobuzTrack
|
var isrcMatches []*QobuzTrack
|
||||||
for i := range result.Tracks.Items {
|
for i := range result.Tracks.Items {
|
||||||
@@ -229,6 +408,8 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
GoLog("[Qobuz] Found %d exact ISRC matches\n", len(isrcMatches))
|
||||||
|
|
||||||
if len(isrcMatches) > 0 {
|
if len(isrcMatches) > 0 {
|
||||||
// Verify duration if provided
|
// Verify duration if provided
|
||||||
if expectedDurationSec > 0 {
|
if expectedDurationSec > 0 {
|
||||||
@@ -238,27 +419,27 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
|
|||||||
if durationDiff < 0 {
|
if durationDiff < 0 {
|
||||||
durationDiff = -durationDiff
|
durationDiff = -durationDiff
|
||||||
}
|
}
|
||||||
// Allow 30 seconds tolerance
|
// Allow 10 seconds tolerance
|
||||||
if durationDiff <= 30 {
|
if durationDiff <= 10 {
|
||||||
durationVerifiedMatches = append(durationVerifiedMatches, track)
|
durationVerifiedMatches = append(durationVerifiedMatches, track)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(durationVerifiedMatches) > 0 {
|
if len(durationVerifiedMatches) > 0 {
|
||||||
fmt.Printf("[Qobuz] ISRC match with duration verification: '%s' (expected %ds, found %ds)\n",
|
GoLog("[Qobuz] ISRC match with duration verification: '%s' (expected %ds, found %ds)\n",
|
||||||
durationVerifiedMatches[0].Title, expectedDurationSec, durationVerifiedMatches[0].Duration)
|
durationVerifiedMatches[0].Title, expectedDurationSec, durationVerifiedMatches[0].Duration)
|
||||||
return durationVerifiedMatches[0], nil
|
return durationVerifiedMatches[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ISRC matches but duration doesn't
|
// ISRC matches but duration doesn't
|
||||||
fmt.Printf("[Qobuz] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n",
|
GoLog("[Qobuz] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n",
|
||||||
isrc, expectedDurationSec, isrcMatches[0].Duration)
|
isrc, expectedDurationSec, isrcMatches[0].Duration)
|
||||||
return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version)",
|
return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version)",
|
||||||
expectedDurationSec, isrcMatches[0].Duration)
|
expectedDurationSec, isrcMatches[0].Duration)
|
||||||
}
|
}
|
||||||
|
|
||||||
// No duration to verify, return first match
|
// No duration to verify, return first match
|
||||||
fmt.Printf("[Qobuz] ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title)
|
GoLog("[Qobuz] ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title)
|
||||||
return isrcMatches[0], nil
|
return isrcMatches[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,6 +462,7 @@ func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*
|
|||||||
|
|
||||||
// SearchTrackByMetadataWithDuration searches for a track with duration verification
|
// SearchTrackByMetadataWithDuration searches for a track with duration verification
|
||||||
// Now includes romaji conversion for Japanese text (same as Tidal)
|
// 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) {
|
func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) {
|
||||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
||||||
|
|
||||||
@@ -312,7 +494,7 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
|||||||
romajiQuery := cleanRomajiArtist + " " + cleanRomajiTrack
|
romajiQuery := cleanRomajiArtist + " " + cleanRomajiTrack
|
||||||
if !containsQueryQobuz(queries, romajiQuery) {
|
if !containsQueryQobuz(queries, romajiQuery) {
|
||||||
queries = append(queries, romajiQuery)
|
queries = append(queries, romajiQuery)
|
||||||
fmt.Printf("[Qobuz] Japanese detected, adding romaji query: %s\n", romajiQuery)
|
GoLog("[Qobuz] Japanese detected, adding romaji query: %s\n", romajiQuery)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,7 +524,7 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
|||||||
}
|
}
|
||||||
searchedQueries[cleanQuery] = true
|
searchedQueries[cleanQuery] = true
|
||||||
|
|
||||||
fmt.Printf("[Qobuz] Searching for: %s\n", cleanQuery)
|
GoLog("[Qobuz] Searching for: %s\n", cleanQuery)
|
||||||
|
|
||||||
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(cleanQuery), q.appID)
|
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(cleanQuery), q.appID)
|
||||||
|
|
||||||
@@ -353,7 +535,7 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
|||||||
|
|
||||||
resp, err := DoRequestWithUserAgent(q.client, req)
|
resp, err := DoRequestWithUserAgent(q.client, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("[Qobuz] Search error for '%s': %v\n", cleanQuery, err)
|
GoLog("[Qobuz] Search error for '%s': %v\n", cleanQuery, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,7 +556,7 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
|||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
|
|
||||||
if len(result.Tracks.Items) > 0 {
|
if len(result.Tracks.Items) > 0 {
|
||||||
fmt.Printf("[Qobuz] Found %d results for '%s'\n", len(result.Tracks.Items), cleanQuery)
|
GoLog("[Qobuz] Found %d results for '%s'\n", len(result.Tracks.Items), cleanQuery)
|
||||||
allTracks = append(allTracks, result.Tracks.Items...)
|
allTracks = append(allTracks, result.Tracks.Items...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -383,16 +565,35 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
|||||||
return nil, fmt.Errorf("no tracks found for: %s - %s", artistName, trackName)
|
return nil, fmt.Errorf("no tracks found for: %s - %s", artistName, trackName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter by title match first (NEW - like Tidal)
|
||||||
|
var titleMatches []*QobuzTrack
|
||||||
|
for i := range allTracks {
|
||||||
|
track := &allTracks[i]
|
||||||
|
if qobuzTitlesMatch(trackName, track.Title) {
|
||||||
|
titleMatches = append(titleMatches, track)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[Qobuz] Title matches: %d out of %d results\n", len(titleMatches), len(allTracks))
|
||||||
|
|
||||||
|
// If no title matches, log warning but continue with all tracks
|
||||||
|
tracksToCheck := titleMatches
|
||||||
|
if len(titleMatches) == 0 {
|
||||||
|
GoLog("[Qobuz] WARNING: No title matches for '%s', checking all %d results\n", trackName, len(allTracks))
|
||||||
|
for i := range allTracks {
|
||||||
|
tracksToCheck = append(tracksToCheck, &allTracks[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// If duration verification is requested
|
// If duration verification is requested
|
||||||
if expectedDurationSec > 0 {
|
if expectedDurationSec > 0 {
|
||||||
var durationMatches []*QobuzTrack
|
var durationMatches []*QobuzTrack
|
||||||
for i := range allTracks {
|
for _, track := range tracksToCheck {
|
||||||
track := &allTracks[i]
|
|
||||||
durationDiff := track.Duration - expectedDurationSec
|
durationDiff := track.Duration - expectedDurationSec
|
||||||
if durationDiff < 0 {
|
if durationDiff < 0 {
|
||||||
durationDiff = -durationDiff
|
durationDiff = -durationDiff
|
||||||
}
|
}
|
||||||
if durationDiff <= 30 {
|
if durationDiff <= 10 {
|
||||||
durationMatches = append(durationMatches, track)
|
durationMatches = append(durationMatches, track)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -401,24 +602,36 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
|||||||
// Return best quality among duration matches
|
// Return best quality among duration matches
|
||||||
for _, track := range durationMatches {
|
for _, track := range durationMatches {
|
||||||
if track.MaximumBitDepth >= 24 {
|
if track.MaximumBitDepth >= 24 {
|
||||||
|
GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title+duration verified, hi-res)\n",
|
||||||
|
track.Title, track.Performer.Name)
|
||||||
return track, nil
|
return track, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title+duration verified)\n",
|
||||||
|
durationMatches[0].Title, durationMatches[0].Performer.Name)
|
||||||
return durationMatches[0], nil
|
return durationMatches[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// No duration match found
|
// No duration match found
|
||||||
return nil, fmt.Errorf("no tracks found with matching duration (expected %ds)", expectedDurationSec)
|
return nil, fmt.Errorf("no tracks found with matching title and duration (expected '%s', %ds)", trackName, expectedDurationSec)
|
||||||
}
|
}
|
||||||
|
|
||||||
// No duration verification, return best quality
|
// No duration verification, return best quality from title matches
|
||||||
for i := range allTracks {
|
for _, track := range tracksToCheck {
|
||||||
track := &allTracks[i]
|
|
||||||
if track.MaximumBitDepth >= 24 {
|
if track.MaximumBitDepth >= 24 {
|
||||||
|
GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title verified, hi-res)\n",
|
||||||
|
track.Title, track.Performer.Name)
|
||||||
return track, nil
|
return track, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return &allTracks[0], nil
|
|
||||||
|
if len(tracksToCheck) > 0 {
|
||||||
|
GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title verified)\n",
|
||||||
|
tracksToCheck[0].Title, tracksToCheck[0].Performer.Name)
|
||||||
|
return tracksToCheck[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("no matching track found for: %s - %s", artistName, trackName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getQobuzDownloadURLSequential requests download URL from APIs sequentially
|
// getQobuzDownloadURLSequential requests download URL from APIs sequentially
|
||||||
@@ -437,7 +650,7 @@ func getQobuzDownloadURLSequential(apis []string, trackID int64, quality string)
|
|||||||
// The apiURL already includes the path, just append trackID and quality
|
// The apiURL already includes the path, just append trackID and quality
|
||||||
reqURL := fmt.Sprintf("%s%d&quality=%s", apiURL, trackID, quality)
|
reqURL := fmt.Sprintf("%s%d&quality=%s", apiURL, trackID, quality)
|
||||||
|
|
||||||
fmt.Printf("[Qobuz] Trying: %s\n", reqURL)
|
GoLog("[Qobuz] Trying: %s\n", reqURL)
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", reqURL, nil)
|
req, err := http.NewRequest("GET", reqURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -482,7 +695,7 @@ func getQobuzDownloadURLSequential(apis []string, trackID int64, quality string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
if result.URL != "" {
|
if result.URL != "" {
|
||||||
fmt.Printf("[Qobuz] Got download URL from: %s\n", apiURL)
|
GoLog("[Qobuz] Got download URL from: %s\n", apiURL)
|
||||||
return apiURL, result.URL, nil
|
return apiURL, result.URL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -592,6 +805,7 @@ type QobuzDownloadResult struct {
|
|||||||
ReleaseDate string
|
ReleaseDate string
|
||||||
TrackNumber int
|
TrackNumber int
|
||||||
DiscNumber int
|
DiscNumber int
|
||||||
|
ISRC string
|
||||||
}
|
}
|
||||||
|
|
||||||
// downloadFromQobuz downloads a track using the request parameters
|
// downloadFromQobuz downloads a track using the request parameters
|
||||||
@@ -612,11 +826,11 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
// OPTIMIZATION: Check cache first for track ID
|
// OPTIMIZATION: Check cache first for track ID
|
||||||
if req.ISRC != "" {
|
if req.ISRC != "" {
|
||||||
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.QobuzTrackID > 0 {
|
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.QobuzTrackID > 0 {
|
||||||
fmt.Printf("[Qobuz] Cache hit! Using cached track ID: %d\n", cached.QobuzTrackID)
|
GoLog("[Qobuz] Cache hit! Using cached track ID: %d\n", cached.QobuzTrackID)
|
||||||
// For Qobuz we need to search again to get full track info, but we can use the ID
|
// For Qobuz we need to search again to get full track info, but we can use the ID
|
||||||
track, err = downloader.SearchTrackByISRC(req.ISRC)
|
track, err = downloader.SearchTrackByISRC(req.ISRC)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("[Qobuz] Cache hit but search failed: %v\n", err)
|
GoLog("[Qobuz] Cache hit but search failed: %v\n", err)
|
||||||
track = nil
|
track = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -624,21 +838,28 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
|
|
||||||
// Strategy 1: Search by ISRC with duration verification
|
// Strategy 1: Search by ISRC with duration verification
|
||||||
if track == nil && req.ISRC != "" {
|
if track == nil && req.ISRC != "" {
|
||||||
|
GoLog("[Qobuz] Trying ISRC search: %s\n", req.ISRC)
|
||||||
track, err = downloader.SearchTrackByISRCWithDuration(req.ISRC, expectedDurationSec)
|
track, err = downloader.SearchTrackByISRCWithDuration(req.ISRC, expectedDurationSec)
|
||||||
// Verify artist
|
// Verify artist AND title
|
||||||
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
|
if track != nil {
|
||||||
fmt.Printf("[Qobuz] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
|
if !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
|
||||||
req.ArtistName, track.Performer.Name)
|
GoLog("[Qobuz] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
|
||||||
track = nil
|
req.ArtistName, track.Performer.Name)
|
||||||
|
track = nil
|
||||||
|
} else if !qobuzTitlesMatch(req.TrackName, track.Title) {
|
||||||
|
GoLog("[Qobuz] Title mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
|
||||||
|
req.TrackName, track.Title)
|
||||||
|
track = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 2: Search by metadata with duration verification
|
// Strategy 2: Search by metadata with duration verification (includes title verification)
|
||||||
if track == nil {
|
if track == nil {
|
||||||
track, err = downloader.SearchTrackByMetadataWithDuration(req.TrackName, req.ArtistName, expectedDurationSec)
|
track, err = downloader.SearchTrackByMetadataWithDuration(req.TrackName, req.ArtistName, expectedDurationSec)
|
||||||
// Verify artist
|
// Verify artist (title already verified in SearchTrackByMetadataWithDuration)
|
||||||
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
|
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
|
||||||
fmt.Printf("[Qobuz] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
|
GoLog("[Qobuz] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
|
||||||
req.ArtistName, track.Performer.Name)
|
req.ArtistName, track.Performer.Name)
|
||||||
track = nil
|
track = nil
|
||||||
}
|
}
|
||||||
@@ -653,7 +874,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Log match found and cache the track ID
|
// Log match found and cache the track ID
|
||||||
fmt.Printf("[Qobuz] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, track.Performer.Name, track.Duration)
|
GoLog("[Qobuz] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, track.Performer.Name, track.Duration)
|
||||||
if req.ISRC != "" {
|
if req.ISRC != "" {
|
||||||
GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
|
GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
|
||||||
}
|
}
|
||||||
@@ -687,12 +908,12 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
case "HI_RES_LOSSLESS":
|
case "HI_RES_LOSSLESS":
|
||||||
qobuzQuality = "27" // 24-bit 192kHz
|
qobuzQuality = "27" // 24-bit 192kHz
|
||||||
}
|
}
|
||||||
fmt.Printf("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality)
|
GoLog("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality)
|
||||||
|
|
||||||
// Get actual quality from track metadata
|
// Get actual quality from track metadata
|
||||||
actualBitDepth := track.MaximumBitDepth
|
actualBitDepth := track.MaximumBitDepth
|
||||||
actualSampleRate := int(track.MaximumSamplingRate * 1000) // Convert kHz to Hz
|
actualSampleRate := int(track.MaximumSamplingRate * 1000) // Convert kHz to Hz
|
||||||
fmt.Printf("[Qobuz] Actual quality: %d-bit/%.1fkHz\n", actualBitDepth, track.MaximumSamplingRate)
|
GoLog("[Qobuz] Actual quality: %d-bit/%.1fkHz\n", actualBitDepth, track.MaximumSamplingRate)
|
||||||
|
|
||||||
// Get download URL using parallel API requests
|
// Get download URL using parallel API requests
|
||||||
downloadURL, err := downloader.GetDownloadURL(track.ID, qobuzQuality)
|
downloadURL, err := downloader.GetDownloadURL(track.ID, qobuzQuality)
|
||||||
@@ -731,11 +952,17 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Embed metadata using parallel-fetched cover data
|
// Embed metadata using parallel-fetched cover data
|
||||||
// Use metadata from the actual Qobuz track found (more accurate than request)
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
metadata := Metadata{
|
metadata := Metadata{
|
||||||
Title: track.Title,
|
Title: track.Title,
|
||||||
Artist: track.Performer.Name,
|
Artist: track.Performer.Name,
|
||||||
Album: track.Album.Title,
|
Album: albumName,
|
||||||
AlbumArtist: req.AlbumArtist, // Qobuz track struct might not have this handy, keep req or check album struct
|
AlbumArtist: req.AlbumArtist, // Qobuz track struct might not have this handy, keep req or check album struct
|
||||||
Date: track.Album.ReleaseDate,
|
Date: track.Album.ReleaseDate,
|
||||||
TrackNumber: track.TrackNumber,
|
TrackNumber: track.TrackNumber,
|
||||||
@@ -748,7 +975,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
var coverData []byte
|
var coverData []byte
|
||||||
if parallelResult != nil && parallelResult.CoverData != nil {
|
if parallelResult != nil && parallelResult.CoverData != nil {
|
||||||
coverData = parallelResult.CoverData
|
coverData = parallelResult.CoverData
|
||||||
fmt.Printf("[Qobuz] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
GoLog("[Qobuz] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
||||||
@@ -757,9 +984,9 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
|
|
||||||
// Embed lyrics from parallel fetch
|
// Embed lyrics from parallel fetch
|
||||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||||
fmt.Printf("[Qobuz] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
GoLog("[Qobuz] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
||||||
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
|
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
|
||||||
fmt.Printf("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr)
|
GoLog("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||||
} else {
|
} else {
|
||||||
fmt.Println("[Qobuz] Lyrics embedded successfully")
|
fmt.Println("[Qobuz] Lyrics embedded successfully")
|
||||||
}
|
}
|
||||||
@@ -780,5 +1007,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
ReleaseDate: track.Album.ReleaseDate,
|
ReleaseDate: track.Album.ReleaseDate,
|
||||||
TrackNumber: track.TrackNumber,
|
TrackNumber: track.TrackNumber,
|
||||||
DiscNumber: req.DiscNumber, // Qobuz track struct limitations
|
DiscNumber: req.DiscNumber, // Qobuz track struct limitations
|
||||||
|
ISRC: track.ISRC,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+455
-91
@@ -133,11 +133,11 @@ func (t *TidalDownloader) GetAvailableAPIs() []string {
|
|||||||
"dGlkYWwtYXBpLmJpbmltdW0ub3Jn", // tidal-api.binimum.org
|
"dGlkYWwtYXBpLmJpbmltdW0ub3Jn", // tidal-api.binimum.org
|
||||||
"dHJpdG9uLnNxdWlkLnd0Zg==", // triton.squid.wtf
|
"dHJpdG9uLnNxdWlkLnd0Zg==", // triton.squid.wtf
|
||||||
// Priority 2: qqdl.site APIs (often return PREVIEW only)
|
// Priority 2: qqdl.site APIs (often return PREVIEW only)
|
||||||
"dm9nZWwucXFkbC5zaXRl", // vogel.qqdl.site
|
"dm9nZWwucXFkbC5zaXRl", // vogel.qqdl.site
|
||||||
"bWF1cy5xcWRsLnNpdGU=", // maus.qqdl.site
|
"bWF1cy5xcWRsLnNpdGU=", // maus.qqdl.site
|
||||||
"aHVuZC5xcWRsLnNpdGU=", // hund.qqdl.site
|
"aHVuZC5xcWRsLnNpdGU=", // hund.qqdl.site
|
||||||
"a2F0emUucXFkbC5zaXRl", // katze.qqdl.site
|
"a2F0emUucXFkbC5zaXRl", // katze.qqdl.site
|
||||||
"d29sZi5xcWRsLnNpdGU=", // wolf.qqdl.site
|
"d29sZi5xcWRsLnNpdGU=", // wolf.qqdl.site
|
||||||
}
|
}
|
||||||
|
|
||||||
var apis []string
|
var apis []string
|
||||||
@@ -297,7 +297,6 @@ func (t *TidalDownloader) GetTrackInfoByID(trackID int64) (*TidalTrack, error) {
|
|||||||
return &trackInfo, nil
|
return &trackInfo, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// SearchTrackByISRC searches for a track by ISRC
|
// SearchTrackByISRC searches for a track by ISRC
|
||||||
func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) {
|
func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) {
|
||||||
token, err := t.GetAccessToken()
|
token, err := t.GetAccessToken()
|
||||||
@@ -404,7 +403,7 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
|||||||
romajiQuery := cleanRomajiArtist + " " + cleanRomajiTrack
|
romajiQuery := cleanRomajiArtist + " " + cleanRomajiTrack
|
||||||
if !containsQuery(queries, romajiQuery) {
|
if !containsQuery(queries, romajiQuery) {
|
||||||
queries = append(queries, romajiQuery)
|
queries = append(queries, romajiQuery)
|
||||||
fmt.Printf("[Tidal] Japanese detected, adding romaji query: %s\n", romajiQuery)
|
GoLog("[Tidal] Japanese detected, adding romaji query: %s\n", romajiQuery)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -445,7 +444,7 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
|||||||
}
|
}
|
||||||
searchedQueries[cleanQuery] = true
|
searchedQueries[cleanQuery] = true
|
||||||
|
|
||||||
fmt.Printf("[Tidal] Searching for: %s\n", cleanQuery)
|
GoLog("[Tidal] Searching for: %s\n", cleanQuery)
|
||||||
|
|
||||||
searchURL := fmt.Sprintf("%s%s&limit=100&countryCode=US", string(searchBase), url.QueryEscape(cleanQuery))
|
searchURL := fmt.Sprintf("%s%s&limit=100&countryCode=US", string(searchBase), url.QueryEscape(cleanQuery))
|
||||||
|
|
||||||
@@ -458,7 +457,7 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
|||||||
|
|
||||||
resp, err := DoRequestWithUserAgent(t.client, req)
|
resp, err := DoRequestWithUserAgent(t.client, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("[Tidal] Search error for '%s': %v\n", cleanQuery, err)
|
GoLog("[Tidal] Search error for '%s': %v\n", cleanQuery, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -477,7 +476,34 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
|||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
|
|
||||||
if len(result.Items) > 0 {
|
if len(result.Items) > 0 {
|
||||||
fmt.Printf("[Tidal] Found %d results for '%s'\n", len(result.Items), cleanQuery)
|
GoLog("[Tidal] Found %d results for '%s'\n", len(result.Items), cleanQuery)
|
||||||
|
|
||||||
|
// OPTIMIZATION: If ISRC provided, check for match immediately and return early
|
||||||
|
if spotifyISRC != "" {
|
||||||
|
for i := range result.Items {
|
||||||
|
if result.Items[i].ISRC == spotifyISRC {
|
||||||
|
track := &result.Items[i]
|
||||||
|
// Verify duration if provided
|
||||||
|
if expectedDuration > 0 {
|
||||||
|
durationDiff := track.Duration - expectedDuration
|
||||||
|
if durationDiff < 0 {
|
||||||
|
durationDiff = -durationDiff
|
||||||
|
}
|
||||||
|
if durationDiff <= 3 {
|
||||||
|
GoLog("[Tidal] ✓ ISRC match: '%s' (duration verified)\n", track.Title)
|
||||||
|
return track, nil
|
||||||
|
}
|
||||||
|
// Duration mismatch, continue searching
|
||||||
|
GoLog("[Tidal] ISRC match but duration mismatch (expected %ds, got %ds), continuing...\n",
|
||||||
|
expectedDuration, track.Duration)
|
||||||
|
} else {
|
||||||
|
GoLog("[Tidal] ✓ ISRC match: '%s'\n", track.Title)
|
||||||
|
return track, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
allTracks = append(allTracks, result.Items...)
|
allTracks = append(allTracks, result.Items...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -488,7 +514,7 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
|||||||
|
|
||||||
// Priority 1: Match by ISRC (exact match) WITH title verification
|
// Priority 1: Match by ISRC (exact match) WITH title verification
|
||||||
if spotifyISRC != "" {
|
if spotifyISRC != "" {
|
||||||
fmt.Printf("[Tidal] Looking for ISRC match: %s\n", spotifyISRC)
|
GoLog("[Tidal] Looking for ISRC match: %s\n", spotifyISRC)
|
||||||
var isrcMatches []*TidalTrack
|
var isrcMatches []*TidalTrack
|
||||||
for i := range allTracks {
|
for i := range allTracks {
|
||||||
track := &allTracks[i]
|
track := &allTracks[i]
|
||||||
@@ -514,25 +540,25 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
|||||||
|
|
||||||
if len(durationVerifiedMatches) > 0 {
|
if len(durationVerifiedMatches) > 0 {
|
||||||
// Return first duration-verified match
|
// Return first duration-verified match
|
||||||
fmt.Printf("[Tidal] ✓ ISRC match with duration verification: '%s' (expected %ds, found %ds)\n",
|
GoLog("[Tidal] ✓ ISRC match with duration verification: '%s' (expected %ds, found %ds)\n",
|
||||||
durationVerifiedMatches[0].Title, expectedDuration, durationVerifiedMatches[0].Duration)
|
durationVerifiedMatches[0].Title, expectedDuration, durationVerifiedMatches[0].Duration)
|
||||||
return durationVerifiedMatches[0], nil
|
return durationVerifiedMatches[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ISRC matches but duration doesn't - this is likely wrong version
|
// ISRC matches but duration doesn't - this is likely wrong version
|
||||||
fmt.Printf("[Tidal] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n",
|
GoLog("[Tidal] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n",
|
||||||
spotifyISRC, expectedDuration, isrcMatches[0].Duration)
|
spotifyISRC, expectedDuration, isrcMatches[0].Duration)
|
||||||
return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version/edit)",
|
return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version/edit)",
|
||||||
expectedDuration, isrcMatches[0].Duration)
|
expectedDuration, isrcMatches[0].Duration)
|
||||||
}
|
}
|
||||||
|
|
||||||
// No duration to verify, just return first ISRC match
|
// No duration to verify, just return first ISRC match
|
||||||
fmt.Printf("[Tidal] ✓ ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title)
|
GoLog("[Tidal] ✓ ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title)
|
||||||
return isrcMatches[0], nil
|
return isrcMatches[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// If ISRC was provided but no match found, return error
|
// If ISRC was provided but no match found, return error
|
||||||
fmt.Printf("[Tidal] ✗ No ISRC match found for: %s\n", spotifyISRC)
|
GoLog("[Tidal] ✗ No ISRC match found for: %s\n", spotifyISRC)
|
||||||
return nil, fmt.Errorf("ISRC mismatch: no track found with ISRC %s on Tidal", spotifyISRC)
|
return nil, fmt.Errorf("ISRC mismatch: no track found with ISRC %s on Tidal", spotifyISRC)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -563,7 +589,7 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fmt.Printf("[Tidal] Found via duration match: %s - %s (%s)\n",
|
GoLog("[Tidal] Found via duration match: %s - %s (%s)\n",
|
||||||
bestMatch.Artist.Name, bestMatch.Title, bestMatch.AudioQuality)
|
bestMatch.Artist.Name, bestMatch.Title, bestMatch.AudioQuality)
|
||||||
return bestMatch, nil
|
return bestMatch, nil
|
||||||
}
|
}
|
||||||
@@ -584,7 +610,7 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("[Tidal] Found via search (no ISRC provided): %s - %s (ISRC: %s, Quality: %s)\n",
|
GoLog("[Tidal] Found via search (no ISRC provided): %s - %s (ISRC: %s, Quality: %s)\n",
|
||||||
bestMatch.Artist.Name, bestMatch.Title, bestMatch.ISRC, bestMatch.AudioQuality)
|
bestMatch.Artist.Name, bestMatch.Title, bestMatch.ISRC, bestMatch.AudioQuality)
|
||||||
|
|
||||||
return bestMatch, nil
|
return bestMatch, nil
|
||||||
@@ -605,7 +631,6 @@ func (t *TidalDownloader) SearchTrackByMetadata(trackName, artistName string) (*
|
|||||||
return t.SearchTrackByMetadataWithISRC(trackName, artistName, "", 0)
|
return t.SearchTrackByMetadataWithISRC(trackName, artistName, "", 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// TidalDownloadInfo contains download URL and quality info
|
// TidalDownloadInfo contains download URL and quality info
|
||||||
type TidalDownloadInfo struct {
|
type TidalDownloadInfo struct {
|
||||||
URL string
|
URL string
|
||||||
@@ -613,7 +638,154 @@ type TidalDownloadInfo struct {
|
|||||||
SampleRate int
|
SampleRate int
|
||||||
}
|
}
|
||||||
|
|
||||||
// getDownloadURLSequential requests download URL from APIs sequentially
|
// tidalAPIResult holds the result from a parallel API request
|
||||||
|
type tidalAPIResult struct {
|
||||||
|
apiURL string
|
||||||
|
info TidalDownloadInfo
|
||||||
|
err error
|
||||||
|
duration time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// getDownloadURLParallel requests download URL from all APIs in parallel
|
||||||
|
// Returns the first successful result (supports both v1 and v2 API formats)
|
||||||
|
func getDownloadURLParallel(apis []string, trackID int64, quality string) (string, TidalDownloadInfo, error) {
|
||||||
|
if len(apis) == 0 {
|
||||||
|
return "", TidalDownloadInfo{}, fmt.Errorf("no APIs available")
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[Tidal] Requesting download URL from %d APIs in parallel...\n", len(apis))
|
||||||
|
|
||||||
|
resultChan := make(chan tidalAPIResult, len(apis))
|
||||||
|
startTime := time.Now()
|
||||||
|
|
||||||
|
// Start all requests in parallel
|
||||||
|
for _, apiURL := range apis {
|
||||||
|
go func(api string) {
|
||||||
|
reqStart := time.Now()
|
||||||
|
|
||||||
|
// Create client with longer timeout for parallel requests
|
||||||
|
client := &http.Client{
|
||||||
|
Timeout: 15 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
reqURL := fmt.Sprintf("%s/track/?id=%d&quality=%s", api, trackID, quality)
|
||||||
|
GoLog("[Tidal] [Parallel] Starting request to: %s\n", api)
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", reqURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[Tidal] [Parallel] %s - Failed to create request: %v\n", api, err)
|
||||||
|
resultChan <- tidalAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[Tidal] [Parallel] %s - Request failed: %v\n", api, err)
|
||||||
|
resultChan <- tidalAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
GoLog("[Tidal] [Parallel] %s - HTTP %d\n", api, resp.StatusCode)
|
||||||
|
resultChan <- tidalAPIResult{apiURL: api, err: fmt.Errorf("HTTP %d", resp.StatusCode), duration: time.Since(reqStart)}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[Tidal] [Parallel] %s - Failed to read body: %v\n", api, err)
|
||||||
|
resultChan <- tidalAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try v2 format first (object with manifest)
|
||||||
|
var v2Response TidalAPIResponseV2
|
||||||
|
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
|
||||||
|
// IMPORTANT: Reject PREVIEW responses - we need FULL tracks
|
||||||
|
if v2Response.Data.AssetPresentation == "PREVIEW" {
|
||||||
|
GoLog("[Tidal] [Parallel] %s - Rejecting PREVIEW response\n", api)
|
||||||
|
resultChan <- tidalAPIResult{apiURL: api, err: fmt.Errorf("returned PREVIEW instead of FULL"), duration: time.Since(reqStart)}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[Tidal] [Parallel] %s - Got FULL track (v2): %d-bit/%dHz in %v\n",
|
||||||
|
api, v2Response.Data.BitDepth, v2Response.Data.SampleRate, time.Since(reqStart))
|
||||||
|
|
||||||
|
info := TidalDownloadInfo{
|
||||||
|
URL: "MANIFEST:" + v2Response.Data.Manifest,
|
||||||
|
BitDepth: v2Response.Data.BitDepth,
|
||||||
|
SampleRate: v2Response.Data.SampleRate,
|
||||||
|
}
|
||||||
|
resultChan <- tidalAPIResult{apiURL: api, info: info, err: nil, duration: time.Since(reqStart)}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to v1 format (array with OriginalTrackUrl)
|
||||||
|
var v1Responses []struct {
|
||||||
|
OriginalTrackURL string `json:"OriginalTrackUrl"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &v1Responses); err == nil {
|
||||||
|
for _, item := range v1Responses {
|
||||||
|
if item.OriginalTrackURL != "" {
|
||||||
|
GoLog("[Tidal] [Parallel] %s - Got direct URL (v1) in %v\n", api, time.Since(reqStart))
|
||||||
|
info := TidalDownloadInfo{
|
||||||
|
URL: item.OriginalTrackURL,
|
||||||
|
BitDepth: 16,
|
||||||
|
SampleRate: 44100,
|
||||||
|
}
|
||||||
|
resultChan <- tidalAPIResult{apiURL: api, info: info, err: nil, duration: time.Since(reqStart)}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[Tidal] [Parallel] %s - No download URL in response\n", api)
|
||||||
|
resultChan <- tidalAPIResult{apiURL: api, err: fmt.Errorf("no download URL or manifest in response"), duration: time.Since(reqStart)}
|
||||||
|
}(apiURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect results - return first success
|
||||||
|
var errors []string
|
||||||
|
successCount := 0
|
||||||
|
failCount := 0
|
||||||
|
|
||||||
|
for i := 0; i < len(apis); i++ {
|
||||||
|
result := <-resultChan
|
||||||
|
if result.err == nil {
|
||||||
|
successCount++
|
||||||
|
if successCount == 1 {
|
||||||
|
// First success - use this one
|
||||||
|
GoLog("[Tidal] [Parallel] ✓ Using response from %s (took %v, total %v)\n",
|
||||||
|
result.apiURL, result.duration, time.Since(startTime))
|
||||||
|
|
||||||
|
// Don't return immediately - let other goroutines finish to avoid leaks
|
||||||
|
// But we'll use this result
|
||||||
|
go func() {
|
||||||
|
// Drain remaining results
|
||||||
|
for j := i + 1; j < len(apis); j++ {
|
||||||
|
<-resultChan
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return result.apiURL, result.info, nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
failCount++
|
||||||
|
errMsg := result.err.Error()
|
||||||
|
if len(errMsg) > 50 {
|
||||||
|
errMsg = errMsg[:50] + "..."
|
||||||
|
}
|
||||||
|
errors = append(errors, fmt.Sprintf("%s: %s", result.apiURL, errMsg))
|
||||||
|
GoLog("[Tidal] [Parallel] ✗ %s failed: %s (took %v)\n", result.apiURL, errMsg, result.duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[Tidal] [Parallel] All %d APIs failed in %v\n", len(apis), time.Since(startTime))
|
||||||
|
return "", TidalDownloadInfo{}, fmt.Errorf("all %d Tidal APIs failed. Errors: %v", len(apis), errors)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getDownloadURLSequential requests download URL from APIs sequentially (fallback)
|
||||||
// Returns the first successful result (supports both v1 and v2 API formats)
|
// Returns the first successful result (supports both v1 and v2 API formats)
|
||||||
func getDownloadURLSequential(apis []string, trackID int64, quality string) (string, TidalDownloadInfo, error) {
|
func getDownloadURLSequential(apis []string, trackID int64, quality string) (string, TidalDownloadInfo, error) {
|
||||||
if len(apis) == 0 {
|
if len(apis) == 0 {
|
||||||
@@ -626,7 +798,7 @@ func getDownloadURLSequential(apis []string, trackID int64, quality string) (str
|
|||||||
|
|
||||||
for _, apiURL := range apis {
|
for _, apiURL := range apis {
|
||||||
reqURL := fmt.Sprintf("%s/track/?id=%d&quality=%s", apiURL, trackID, quality)
|
reqURL := fmt.Sprintf("%s/track/?id=%d&quality=%s", apiURL, trackID, quality)
|
||||||
fmt.Printf("[Tidal] Trying API: %s\n", reqURL)
|
GoLog("[Tidal] Trying API: %s\n", reqURL)
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", reqURL, nil)
|
req, err := http.NewRequest("GET", reqURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -636,7 +808,7 @@ func getDownloadURLSequential(apis []string, trackID int64, quality string) (str
|
|||||||
|
|
||||||
resp, err := DoRequestWithRetry(client, req, retryConfig)
|
resp, err := DoRequestWithRetry(client, req, retryConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("[Tidal] API error: %v\n", err)
|
GoLog("[Tidal] API error: %v\n", err)
|
||||||
errors = append(errors, BuildErrorMessage(apiURL, 0, err.Error()))
|
errors = append(errors, BuildErrorMessage(apiURL, 0, err.Error()))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -644,7 +816,7 @@ func getDownloadURLSequential(apis []string, trackID int64, quality string) (str
|
|||||||
body, err := ReadResponseBody(resp)
|
body, err := ReadResponseBody(resp)
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("[Tidal] Read body error: %v\n", err)
|
GoLog("[Tidal] Read body error: %v\n", err)
|
||||||
errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, err.Error()))
|
errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, err.Error()))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -654,22 +826,22 @@ func getDownloadURLSequential(apis []string, trackID int64, quality string) (str
|
|||||||
if len(bodyPreview) > 300 {
|
if len(bodyPreview) > 300 {
|
||||||
bodyPreview = bodyPreview[:300] + "..."
|
bodyPreview = bodyPreview[:300] + "..."
|
||||||
}
|
}
|
||||||
fmt.Printf("[Tidal] API response (HTTP %d): %s\n", resp.StatusCode, bodyPreview)
|
GoLog("[Tidal] API response (HTTP %d): %s\n", resp.StatusCode, bodyPreview)
|
||||||
|
|
||||||
// Try v2 format first (object with manifest)
|
// Try v2 format first (object with manifest)
|
||||||
var v2Response TidalAPIResponseV2
|
var v2Response TidalAPIResponseV2
|
||||||
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
|
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
|
||||||
fmt.Printf("[Tidal] Got v2 response from %s - Quality: %d-bit/%dHz, AssetPresentation: %s\n",
|
GoLog("[Tidal] Got v2 response from %s - Quality: %d-bit/%dHz, AssetPresentation: %s\n",
|
||||||
apiURL, v2Response.Data.BitDepth, v2Response.Data.SampleRate, v2Response.Data.AssetPresentation)
|
apiURL, v2Response.Data.BitDepth, v2Response.Data.SampleRate, v2Response.Data.AssetPresentation)
|
||||||
|
|
||||||
// IMPORTANT: Reject PREVIEW responses - we need FULL tracks
|
// IMPORTANT: Reject PREVIEW responses - we need FULL tracks
|
||||||
if v2Response.Data.AssetPresentation == "PREVIEW" {
|
if v2Response.Data.AssetPresentation == "PREVIEW" {
|
||||||
fmt.Printf("[Tidal] ✗ Rejecting PREVIEW response from %s, trying next API...\n", apiURL)
|
GoLog("[Tidal] ✗ Rejecting PREVIEW response from %s, trying next API...\n", apiURL)
|
||||||
errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, "returned PREVIEW instead of FULL"))
|
errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, "returned PREVIEW instead of FULL"))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("[Tidal] ✓ Got FULL track from %s\n", apiURL)
|
GoLog("[Tidal] ✓ Got FULL track from %s\n", apiURL)
|
||||||
info := TidalDownloadInfo{
|
info := TidalDownloadInfo{
|
||||||
URL: "MANIFEST:" + v2Response.Data.Manifest,
|
URL: "MANIFEST:" + v2Response.Data.Manifest,
|
||||||
BitDepth: v2Response.Data.BitDepth,
|
BitDepth: v2Response.Data.BitDepth,
|
||||||
@@ -731,7 +903,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
|||||||
if len(manifestPreview) > 500 {
|
if len(manifestPreview) > 500 {
|
||||||
manifestPreview = manifestPreview[:500] + "..."
|
manifestPreview = manifestPreview[:500] + "..."
|
||||||
}
|
}
|
||||||
fmt.Printf("[Tidal] Manifest content: %s\n", manifestPreview)
|
GoLog("[Tidal] Manifest content: %s\n", manifestPreview)
|
||||||
|
|
||||||
// Check if it's BTS format (JSON) or DASH format (XML)
|
// Check if it's BTS format (JSON) or DASH format (XML)
|
||||||
if strings.HasPrefix(manifestStr, "{") {
|
if strings.HasPrefix(manifestStr, "{") {
|
||||||
@@ -781,12 +953,12 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
|||||||
|
|
||||||
// Calculate segment count from timeline
|
// Calculate segment count from timeline
|
||||||
segmentCount := 0
|
segmentCount := 0
|
||||||
fmt.Printf("[Tidal] XML parsed segments: %d entries in timeline\n", len(segTemplate.Timeline.Segments))
|
GoLog("[Tidal] XML parsed segments: %d entries in timeline\n", len(segTemplate.Timeline.Segments))
|
||||||
for i, seg := range segTemplate.Timeline.Segments {
|
for i, seg := range segTemplate.Timeline.Segments {
|
||||||
fmt.Printf("[Tidal] Segment[%d]: d=%d, r=%d\n", i, seg.Duration, seg.Repeat)
|
GoLog("[Tidal] Segment[%d]: d=%d, r=%d\n", i, seg.Duration, seg.Repeat)
|
||||||
segmentCount += seg.Repeat + 1
|
segmentCount += seg.Repeat + 1
|
||||||
}
|
}
|
||||||
fmt.Printf("[Tidal] Segment count from XML: %d\n", segmentCount)
|
GoLog("[Tidal] Segment count from XML: %d\n", segmentCount)
|
||||||
|
|
||||||
// If no segments found via XML, try regex
|
// If no segments found via XML, try regex
|
||||||
if segmentCount == 0 {
|
if segmentCount == 0 {
|
||||||
@@ -794,18 +966,18 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
|||||||
// Match <S d="..." /> or <S d="..." r="..." />
|
// Match <S d="..." /> or <S d="..." r="..." />
|
||||||
segRe := regexp.MustCompile(`<S\s+d="(\d+)"(?:\s+r="(\d+)")?`)
|
segRe := regexp.MustCompile(`<S\s+d="(\d+)"(?:\s+r="(\d+)")?`)
|
||||||
matches := segRe.FindAllStringSubmatch(manifestStr, -1)
|
matches := segRe.FindAllStringSubmatch(manifestStr, -1)
|
||||||
fmt.Printf("[Tidal] Regex found %d segment entries\n", len(matches))
|
GoLog("[Tidal] Regex found %d segment entries\n", len(matches))
|
||||||
for i, match := range matches {
|
for i, match := range matches {
|
||||||
repeat := 0
|
repeat := 0
|
||||||
if len(match) > 2 && match[2] != "" {
|
if len(match) > 2 && match[2] != "" {
|
||||||
fmt.Sscanf(match[2], "%d", &repeat)
|
fmt.Sscanf(match[2], "%d", &repeat)
|
||||||
}
|
}
|
||||||
if i < 5 || i == len(matches)-1 {
|
if i < 5 || i == len(matches)-1 {
|
||||||
fmt.Printf("[Tidal] Regex segment[%d]: d=%s, r=%d\n", i, match[1], repeat)
|
GoLog("[Tidal] Regex segment[%d]: d=%s, r=%d\n", i, match[1], repeat)
|
||||||
}
|
}
|
||||||
segmentCount += repeat + 1
|
segmentCount += repeat + 1
|
||||||
}
|
}
|
||||||
fmt.Printf("[Tidal] Total segments from regex: %d\n", segmentCount)
|
GoLog("[Tidal] Total segments from regex: %d\n", segmentCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate media URLs for each segment
|
// Generate media URLs for each segment
|
||||||
@@ -817,7 +989,6 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
|||||||
return "", initURL, mediaURLs, nil
|
return "", initURL, mediaURLs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// DownloadFile downloads a file from URL with progress tracking
|
// DownloadFile downloads a file from URL with progress tracking
|
||||||
func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||||
// Handle manifest-based download (DASH/BTS)
|
// Handle manifest-based download (DASH/BTS)
|
||||||
@@ -906,10 +1077,10 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
|||||||
fmt.Println("[Tidal] Parsing manifest...")
|
fmt.Println("[Tidal] Parsing manifest...")
|
||||||
directURL, initURL, mediaURLs, err := parseManifest(manifestB64)
|
directURL, initURL, mediaURLs, err := parseManifest(manifestB64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("[Tidal] Manifest parse error: %v\n", err)
|
GoLog("[Tidal] Manifest parse error: %v\n", err)
|
||||||
return fmt.Errorf("failed to parse manifest: %w", err)
|
return fmt.Errorf("failed to parse manifest: %w", err)
|
||||||
}
|
}
|
||||||
fmt.Printf("[Tidal] Manifest parsed - directURL: %v, initURL: %v, mediaURLs count: %d\n",
|
GoLog("[Tidal] Manifest parsed - directURL: %v, initURL: %v, mediaURLs count: %d\n",
|
||||||
directURL != "", initURL != "", len(mediaURLs))
|
directURL != "", initURL != "", len(mediaURLs))
|
||||||
|
|
||||||
client := &http.Client{
|
client := &http.Client{
|
||||||
@@ -918,27 +1089,27 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
|||||||
|
|
||||||
// If we have a direct URL (BTS format), download directly with progress tracking
|
// If we have a direct URL (BTS format), download directly with progress tracking
|
||||||
if directURL != "" {
|
if directURL != "" {
|
||||||
fmt.Printf("[Tidal] BTS format - downloading from direct URL: %s...\n", directURL[:min(80, len(directURL))])
|
GoLog("[Tidal] BTS format - downloading from direct URL: %s...\n", directURL[:min(80, len(directURL))])
|
||||||
// Note: Progress tracking is initialized by the caller (DownloadFile)
|
// Note: Progress tracking is initialized by the caller (DownloadFile)
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", directURL, nil)
|
req, err := http.NewRequest("GET", directURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("[Tidal] BTS request creation failed: %v\n", err)
|
GoLog("[Tidal] BTS request creation failed: %v\n", err)
|
||||||
return fmt.Errorf("failed to create request: %w", err)
|
return fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("[Tidal] BTS download failed: %v\n", err)
|
GoLog("[Tidal] BTS download failed: %v\n", err)
|
||||||
return fmt.Errorf("failed to download file: %w", err)
|
return fmt.Errorf("failed to download file: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
if resp.StatusCode != 200 {
|
||||||
fmt.Printf("[Tidal] BTS download HTTP error: %d\n", resp.StatusCode)
|
GoLog("[Tidal] BTS download HTTP error: %d\n", resp.StatusCode)
|
||||||
return fmt.Errorf("download failed with status %d", resp.StatusCode)
|
return fmt.Errorf("download failed with status %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
fmt.Printf("[Tidal] BTS response OK, Content-Length: %d\n", resp.ContentLength)
|
GoLog("[Tidal] BTS response OK, Content-Length: %d\n", resp.ContentLength)
|
||||||
|
|
||||||
expectedSize := resp.ContentLength
|
expectedSize := resp.ContentLength
|
||||||
// Set total bytes for progress tracking
|
// Set total bytes for progress tracking
|
||||||
@@ -983,31 +1154,31 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
|||||||
// DASH format - download segments directly to M4A file (no temp file to avoid Android permission issues)
|
// DASH format - download segments directly to M4A file (no temp file to avoid Android permission issues)
|
||||||
// On Android, we can't use ffmpeg, so we save as M4A directly
|
// On Android, we can't use ffmpeg, so we save as M4A directly
|
||||||
m4aPath := strings.TrimSuffix(outputPath, ".flac") + ".m4a"
|
m4aPath := strings.TrimSuffix(outputPath, ".flac") + ".m4a"
|
||||||
fmt.Printf("[Tidal] DASH format - downloading %d segments directly to: %s\n", len(mediaURLs), m4aPath)
|
GoLog("[Tidal] DASH format - downloading %d segments directly to: %s\n", len(mediaURLs), m4aPath)
|
||||||
|
|
||||||
// Note: Progress tracking is initialized by the caller (DownloadFile or downloadFromTidal)
|
// Note: Progress tracking is initialized by the caller (DownloadFile or downloadFromTidal)
|
||||||
// We just update progress here based on segment count
|
// We just update progress here based on segment count
|
||||||
|
|
||||||
out, err := os.Create(m4aPath)
|
out, err := os.Create(m4aPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("[Tidal] Failed to create M4A file: %v\n", err)
|
GoLog("[Tidal] Failed to create M4A file: %v\n", err)
|
||||||
return fmt.Errorf("failed to create M4A file: %w", err)
|
return fmt.Errorf("failed to create M4A file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download initialization segment
|
// Download initialization segment
|
||||||
fmt.Printf("[Tidal] Downloading init segment...\n")
|
GoLog("[Tidal] Downloading init segment...\n")
|
||||||
resp, err := client.Get(initURL)
|
resp, err := client.Get(initURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
out.Close()
|
out.Close()
|
||||||
os.Remove(m4aPath)
|
os.Remove(m4aPath)
|
||||||
fmt.Printf("[Tidal] Init segment download failed: %v\n", err)
|
GoLog("[Tidal] Init segment download failed: %v\n", err)
|
||||||
return fmt.Errorf("failed to download init segment: %w", err)
|
return fmt.Errorf("failed to download init segment: %w", err)
|
||||||
}
|
}
|
||||||
if resp.StatusCode != 200 {
|
if resp.StatusCode != 200 {
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
out.Close()
|
out.Close()
|
||||||
os.Remove(m4aPath)
|
os.Remove(m4aPath)
|
||||||
fmt.Printf("[Tidal] Init segment HTTP error: %d\n", resp.StatusCode)
|
GoLog("[Tidal] Init segment HTTP error: %d\n", resp.StatusCode)
|
||||||
return fmt.Errorf("init segment download failed with status %d", resp.StatusCode)
|
return fmt.Errorf("init segment download failed with status %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
_, err = io.Copy(out, resp.Body)
|
_, err = io.Copy(out, resp.Body)
|
||||||
@@ -1015,7 +1186,7 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
out.Close()
|
out.Close()
|
||||||
os.Remove(m4aPath)
|
os.Remove(m4aPath)
|
||||||
fmt.Printf("[Tidal] Init segment write failed: %v\n", err)
|
GoLog("[Tidal] Init segment write failed: %v\n", err)
|
||||||
return fmt.Errorf("failed to write init segment: %w", err)
|
return fmt.Errorf("failed to write init segment: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1023,7 +1194,7 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
|||||||
totalSegments := len(mediaURLs)
|
totalSegments := len(mediaURLs)
|
||||||
for i, mediaURL := range mediaURLs {
|
for i, mediaURL := range mediaURLs {
|
||||||
if i%10 == 0 || i == totalSegments-1 {
|
if i%10 == 0 || i == totalSegments-1 {
|
||||||
fmt.Printf("[Tidal] Downloading segment %d/%d...\n", i+1, totalSegments)
|
GoLog("[Tidal] Downloading segment %d/%d...\n", i+1, totalSegments)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update progress based on segment count
|
// Update progress based on segment count
|
||||||
@@ -1036,14 +1207,14 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
out.Close()
|
out.Close()
|
||||||
os.Remove(m4aPath)
|
os.Remove(m4aPath)
|
||||||
fmt.Printf("[Tidal] Segment %d download failed: %v\n", i+1, err)
|
GoLog("[Tidal] Segment %d download failed: %v\n", i+1, err)
|
||||||
return fmt.Errorf("failed to download segment %d: %w", i+1, err)
|
return fmt.Errorf("failed to download segment %d: %w", i+1, err)
|
||||||
}
|
}
|
||||||
if resp.StatusCode != 200 {
|
if resp.StatusCode != 200 {
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
out.Close()
|
out.Close()
|
||||||
os.Remove(m4aPath)
|
os.Remove(m4aPath)
|
||||||
fmt.Printf("[Tidal] Segment %d HTTP error: %d\n", i+1, resp.StatusCode)
|
GoLog("[Tidal] Segment %d HTTP error: %d\n", i+1, resp.StatusCode)
|
||||||
return fmt.Errorf("segment %d download failed with status %d", i+1, resp.StatusCode)
|
return fmt.Errorf("segment %d download failed with status %d", i+1, resp.StatusCode)
|
||||||
}
|
}
|
||||||
_, err = io.Copy(out, resp.Body)
|
_, err = io.Copy(out, resp.Body)
|
||||||
@@ -1051,18 +1222,18 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
out.Close()
|
out.Close()
|
||||||
os.Remove(m4aPath)
|
os.Remove(m4aPath)
|
||||||
fmt.Printf("[Tidal] Segment %d write failed: %v\n", i+1, err)
|
GoLog("[Tidal] Segment %d write failed: %v\n", i+1, err)
|
||||||
return fmt.Errorf("failed to write segment %d: %w", i+1, err)
|
return fmt.Errorf("failed to write segment %d: %w", i+1, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := out.Close(); err != nil {
|
if err := out.Close(); err != nil {
|
||||||
os.Remove(m4aPath)
|
os.Remove(m4aPath)
|
||||||
fmt.Printf("[Tidal] Failed to close M4A file: %v\n", err)
|
GoLog("[Tidal] Failed to close M4A file: %v\n", err)
|
||||||
return fmt.Errorf("failed to close M4A file: %w", err)
|
return fmt.Errorf("failed to close M4A file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("[Tidal] DASH download completed: %s\n", m4aPath)
|
GoLog("[Tidal] DASH download completed: %s\n", m4aPath)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1077,6 +1248,7 @@ type TidalDownloadResult struct {
|
|||||||
ReleaseDate string
|
ReleaseDate string
|
||||||
TrackNumber int
|
TrackNumber int
|
||||||
DiscNumber int
|
DiscNumber int
|
||||||
|
ISRC string
|
||||||
}
|
}
|
||||||
|
|
||||||
// artistsMatch checks if the artist names are similar enough
|
// artistsMatch checks if the artist names are similar enough
|
||||||
@@ -1114,19 +1286,192 @@ func artistsMatch(spotifyArtist, tidalArtist string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean),
|
// If scripts are TRULY different (Latin vs CJK/Arabic/Cyrillic), assume match (transliteration)
|
||||||
// assume they're the same artist with different transliteration
|
// Don't treat Latin Extended (Polish, French, etc.) as different script
|
||||||
// This handles cases like "鈴木雅之" vs "Masayuki Suzuki"
|
// This handles cases like "鈴木雅之" vs "Masayuki Suzuki"
|
||||||
spotifyASCII := isASCIIString(spotifyArtist)
|
spotifyLatin := isLatinScript(spotifyArtist)
|
||||||
tidalASCII := isASCIIString(tidalArtist)
|
tidalLatin := isLatinScript(tidalArtist)
|
||||||
if spotifyASCII != tidalASCII {
|
if spotifyLatin != tidalLatin {
|
||||||
fmt.Printf("[Tidal] Artist names in different scripts, assuming match: '%s' vs '%s'\n", spotifyArtist, tidalArtist)
|
GoLog("[Tidal] Artist names in different scripts, assuming match: '%s' vs '%s'\n", spotifyArtist, tidalArtist)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// titlesMatch checks if track titles are similar enough
|
||||||
|
func titlesMatch(expectedTitle, foundTitle string) bool {
|
||||||
|
normExpected := strings.ToLower(strings.TrimSpace(expectedTitle))
|
||||||
|
normFound := strings.ToLower(strings.TrimSpace(foundTitle))
|
||||||
|
|
||||||
|
// Exact match
|
||||||
|
if normExpected == normFound {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if one contains the other
|
||||||
|
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean both titles and compare
|
||||||
|
cleanExpected := cleanTitle(normExpected)
|
||||||
|
cleanFound := cleanTitle(normFound)
|
||||||
|
|
||||||
|
if cleanExpected == cleanFound {
|
||||||
|
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 := extractCoreTitle(normExpected)
|
||||||
|
coreFound := extractCoreTitle(normFound)
|
||||||
|
|
||||||
|
if coreExpected != "" && coreFound != "" && coreExpected == coreFound {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 := isLatinScript(expectedTitle)
|
||||||
|
foundLatin := isLatinScript(foundTitle)
|
||||||
|
if expectedLatin != foundLatin {
|
||||||
|
GoLog("[Tidal] Titles in different scripts, assuming match: '%s' vs '%s'\n", expectedTitle, foundTitle)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractCoreTitle extracts the main title before any parentheses or brackets
|
||||||
|
func extractCoreTitle(title string) string {
|
||||||
|
// Find first occurrence of ( or [
|
||||||
|
parenIdx := strings.Index(title, "(")
|
||||||
|
bracketIdx := strings.Index(title, "[")
|
||||||
|
dashIdx := strings.Index(title, " - ")
|
||||||
|
|
||||||
|
cutIdx := len(title)
|
||||||
|
if parenIdx > 0 && parenIdx < cutIdx {
|
||||||
|
cutIdx = parenIdx
|
||||||
|
}
|
||||||
|
if bracketIdx > 0 && bracketIdx < cutIdx {
|
||||||
|
cutIdx = bracketIdx
|
||||||
|
}
|
||||||
|
if dashIdx > 0 && dashIdx < cutIdx {
|
||||||
|
cutIdx = dashIdx
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(title[:cutIdx])
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanTitle removes common suffixes from track titles for comparison
|
||||||
|
func cleanTitle(title string) string {
|
||||||
|
cleaned := title
|
||||||
|
|
||||||
|
// Version indicators to remove from parentheses/brackets
|
||||||
|
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, ")")
|
||||||
|
if startParen >= 0 && endParen > startParen {
|
||||||
|
content := strings.ToLower(cleaned[startParen+1 : endParen])
|
||||||
|
isVersionIndicator := false
|
||||||
|
for _, pattern := range versionPatterns {
|
||||||
|
if strings.Contains(content, pattern) {
|
||||||
|
isVersionIndicator = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isVersionIndicator {
|
||||||
|
cleaned = strings.TrimSpace(cleaned[:startParen]) + cleaned[endParen+1:]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same for brackets
|
||||||
|
for {
|
||||||
|
startBracket := strings.LastIndex(cleaned, "[")
|
||||||
|
endBracket := strings.LastIndex(cleaned, "]")
|
||||||
|
if startBracket >= 0 && endBracket > startBracket {
|
||||||
|
content := strings.ToLower(cleaned[startBracket+1 : endBracket])
|
||||||
|
isVersionIndicator := false
|
||||||
|
for _, pattern := range versionPatterns {
|
||||||
|
if strings.Contains(content, pattern) {
|
||||||
|
isVersionIndicator = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isVersionIndicator {
|
||||||
|
cleaned = strings.TrimSpace(cleaned[:startBracket]) + cleaned[endBracket+1:]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove trailing " - version" patterns
|
||||||
|
dashPatterns := []string{
|
||||||
|
" - remaster", " - remastered", " - single version", " - radio edit",
|
||||||
|
" - live", " - acoustic", " - demo", " - remix",
|
||||||
|
}
|
||||||
|
for _, pattern := range dashPatterns {
|
||||||
|
if strings.HasSuffix(strings.ToLower(cleaned), pattern) {
|
||||||
|
cleaned = cleaned[:len(cleaned)-len(pattern)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove multiple spaces
|
||||||
|
for strings.Contains(cleaned, " ") {
|
||||||
|
cleaned = strings.ReplaceAll(cleaned, " ", " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(cleaned)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isLatinScript checks if a string is primarily Latin script
|
||||||
|
// Returns true for ASCII and Latin Extended characters (European languages)
|
||||||
|
// Returns false for CJK, Arabic, Cyrillic, etc.
|
||||||
|
func isLatinScript(s string) bool {
|
||||||
|
for _, r := range s {
|
||||||
|
// Skip common punctuation and numbers
|
||||||
|
if r < 128 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Latin Extended-A: U+0100 to U+017F (Polish, Czech, etc.)
|
||||||
|
// Latin Extended-B: U+0180 to U+024F
|
||||||
|
// Latin Extended Additional: U+1E00 to U+1EFF
|
||||||
|
if (r >= 0x0100 && r <= 0x024F) || // Latin Extended A & B
|
||||||
|
(r >= 0x1E00 && r <= 0x1EFF) || // Latin Extended Additional
|
||||||
|
(r >= 0x00C0 && r <= 0x00FF) { // Latin-1 Supplement (accented chars)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// CJK ranges - definitely different script
|
||||||
|
if (r >= 0x4E00 && r <= 0x9FFF) || // CJK Unified Ideographs
|
||||||
|
(r >= 0x3040 && r <= 0x309F) || // Hiragana
|
||||||
|
(r >= 0x30A0 && r <= 0x30FF) || // Katakana
|
||||||
|
(r >= 0xAC00 && r <= 0xD7AF) || // Hangul (Korean)
|
||||||
|
(r >= 0x0600 && r <= 0x06FF) || // Arabic
|
||||||
|
(r >= 0x0400 && r <= 0x04FF) { // Cyrillic
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// isASCIIString checks if a string contains only ASCII characters
|
// isASCIIString checks if a string contains only ASCII characters
|
||||||
func isASCIIString(s string) bool {
|
func isASCIIString(s string) bool {
|
||||||
for _, r := range s {
|
for _, r := range s {
|
||||||
@@ -1155,22 +1500,22 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
// OPTIMIZATION: Check cache first for track ID
|
// OPTIMIZATION: Check cache first for track ID
|
||||||
if req.ISRC != "" {
|
if req.ISRC != "" {
|
||||||
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.TidalTrackID > 0 {
|
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.TidalTrackID > 0 {
|
||||||
fmt.Printf("[Tidal] Cache hit! Using cached track ID: %d\n", cached.TidalTrackID)
|
GoLog("[Tidal] Cache hit! Using cached track ID: %d\n", cached.TidalTrackID)
|
||||||
track, err = downloader.GetTrackInfoByID(cached.TidalTrackID)
|
track, err = downloader.GetTrackInfoByID(cached.TidalTrackID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("[Tidal] Cache hit but failed to get track info: %v\n", err)
|
GoLog("[Tidal] Cache hit but failed to get track info: %v\n", err)
|
||||||
track = nil // Fall through to normal search
|
track = nil // Fall through to normal search
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// OPTIMIZED: Try ISRC search first (faster than SongLink API)
|
// OPTIMIZED: Try ISRC search with metadata (search by name, filter by ISRC)
|
||||||
// Strategy 1: Search by ISRC with duration verification (FASTEST)
|
// Strategy 1: Search by metadata, match by ISRC (most accurate)
|
||||||
if track == nil && req.ISRC != "" {
|
if track == nil && req.ISRC != "" {
|
||||||
fmt.Printf("[Tidal] Trying ISRC search first (faster): %s\n", req.ISRC)
|
GoLog("[Tidal] Trying ISRC search: %s\n", req.ISRC)
|
||||||
track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, req.ISRC, expectedDurationSec)
|
track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, req.ISRC, expectedDurationSec)
|
||||||
// Verify artist for ISRC match
|
|
||||||
if track != nil {
|
if track != nil {
|
||||||
|
// Verify artist only (ISRC match is already accurate)
|
||||||
tidalArtist := track.Artist.Name
|
tidalArtist := track.Artist.Name
|
||||||
if len(track.Artists) > 0 {
|
if len(track.Artists) > 0 {
|
||||||
var artistNames []string
|
var artistNames []string
|
||||||
@@ -1180,7 +1525,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
tidalArtist = strings.Join(artistNames, ", ")
|
tidalArtist = strings.Join(artistNames, ", ")
|
||||||
}
|
}
|
||||||
if !artistsMatch(req.ArtistName, tidalArtist) {
|
if !artistsMatch(req.ArtistName, tidalArtist) {
|
||||||
fmt.Printf("[Tidal] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
|
GoLog("[Tidal] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
|
||||||
req.ArtistName, tidalArtist)
|
req.ArtistName, tidalArtist)
|
||||||
track = nil
|
track = nil
|
||||||
}
|
}
|
||||||
@@ -1189,8 +1534,20 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
|
|
||||||
// Strategy 2: Try SongLink only if ISRC search failed (slower but more accurate)
|
// Strategy 2: Try SongLink only if ISRC search failed (slower but more accurate)
|
||||||
if track == nil && req.SpotifyID != "" {
|
if track == nil && req.SpotifyID != "" {
|
||||||
fmt.Printf("[Tidal] ISRC search failed, trying SongLink...\n")
|
GoLog("[Tidal] ISRC search failed, trying SongLink...\n")
|
||||||
tidalURL, slErr := downloader.GetTidalURLFromSpotify(req.SpotifyID)
|
var tidalURL string
|
||||||
|
var slErr error
|
||||||
|
|
||||||
|
// Check if SpotifyID is actually a Deezer ID (format: "deezer:xxxxx")
|
||||||
|
if strings.HasPrefix(req.SpotifyID, "deezer:") {
|
||||||
|
deezerID := strings.TrimPrefix(req.SpotifyID, "deezer:")
|
||||||
|
GoLog("[Tidal] Using Deezer ID for SongLink lookup: %s\n", deezerID)
|
||||||
|
songlink := NewSongLinkClient()
|
||||||
|
tidalURL, slErr = songlink.GetTidalURLFromDeezer(deezerID)
|
||||||
|
} else {
|
||||||
|
tidalURL, slErr = downloader.GetTidalURLFromSpotify(req.SpotifyID)
|
||||||
|
}
|
||||||
|
|
||||||
if slErr == nil && tidalURL != "" {
|
if slErr == nil && tidalURL != "" {
|
||||||
// Extract track ID and get track info
|
// Extract track ID and get track info
|
||||||
trackID, idErr := downloader.GetTrackIDFromURL(tidalURL)
|
trackID, idErr := downloader.GetTrackIDFromURL(tidalURL)
|
||||||
@@ -1207,9 +1564,9 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
tidalArtist = strings.Join(artistNames, ", ")
|
tidalArtist = strings.Join(artistNames, ", ")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify artist matches
|
// Verify artist matches (SongLink is already accurate, no title check needed)
|
||||||
if !artistsMatch(req.ArtistName, tidalArtist) {
|
if !artistsMatch(req.ArtistName, tidalArtist) {
|
||||||
fmt.Printf("[Tidal] Artist mismatch from SongLink: expected '%s', got '%s'. Rejecting.\n",
|
GoLog("[Tidal] Artist mismatch from SongLink: expected '%s', got '%s'. Rejecting.\n",
|
||||||
req.ArtistName, tidalArtist)
|
req.ArtistName, tidalArtist)
|
||||||
track = nil
|
track = nil
|
||||||
}
|
}
|
||||||
@@ -1222,7 +1579,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
// Allow 3 seconds tolerance (same as PC version)
|
// Allow 3 seconds tolerance (same as PC version)
|
||||||
if durationDiff > 3 {
|
if durationDiff > 3 {
|
||||||
fmt.Printf("[Tidal] Duration mismatch from SongLink: expected %ds, got %ds. Rejecting.\n",
|
GoLog("[Tidal] Duration mismatch from SongLink: expected %ds, got %ds. Rejecting.\n",
|
||||||
expectedDurationSec, track.Duration)
|
expectedDurationSec, track.Duration)
|
||||||
track = nil // Reject this match
|
track = nil // Reject this match
|
||||||
}
|
}
|
||||||
@@ -1234,9 +1591,9 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
|
|
||||||
// Strategy 3: Search by metadata only (no ISRC requirement) - last resort
|
// Strategy 3: Search by metadata only (no ISRC requirement) - last resort
|
||||||
if track == nil {
|
if track == nil {
|
||||||
fmt.Printf("[Tidal] Trying metadata search as last resort...\n")
|
GoLog("[Tidal] Trying metadata search as last resort...\n")
|
||||||
track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, "", expectedDurationSec)
|
track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, "", expectedDurationSec)
|
||||||
// Verify artist for metadata search too
|
// Verify artist AND title for metadata search
|
||||||
if track != nil {
|
if track != nil {
|
||||||
tidalArtist := track.Artist.Name
|
tidalArtist := track.Artist.Name
|
||||||
if len(track.Artists) > 0 {
|
if len(track.Artists) > 0 {
|
||||||
@@ -1246,8 +1603,14 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
tidalArtist = strings.Join(artistNames, ", ")
|
tidalArtist = strings.Join(artistNames, ", ")
|
||||||
}
|
}
|
||||||
if !artistsMatch(req.ArtistName, tidalArtist) {
|
|
||||||
fmt.Printf("[Tidal] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
|
// Verify title first
|
||||||
|
if !titlesMatch(req.TrackName, track.Title) {
|
||||||
|
GoLog("[Tidal] Title mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
|
||||||
|
req.TrackName, track.Title)
|
||||||
|
track = nil
|
||||||
|
} else if !artistsMatch(req.ArtistName, tidalArtist) {
|
||||||
|
GoLog("[Tidal] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
|
||||||
req.ArtistName, tidalArtist)
|
req.ArtistName, tidalArtist)
|
||||||
track = nil
|
track = nil
|
||||||
}
|
}
|
||||||
@@ -1271,7 +1634,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
tidalArtist = strings.Join(artistNames, ", ")
|
tidalArtist = strings.Join(artistNames, ", ")
|
||||||
}
|
}
|
||||||
fmt.Printf("[Tidal] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, tidalArtist, track.Duration)
|
GoLog("[Tidal] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, tidalArtist, track.Duration)
|
||||||
|
|
||||||
// Cache the track ID for future use
|
// Cache the track ID for future use
|
||||||
if req.ISRC != "" {
|
if req.ISRC != "" {
|
||||||
@@ -1302,7 +1665,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
// Clean up any leftover .tmp files from previous failed downloads
|
// Clean up any leftover .tmp files from previous failed downloads
|
||||||
tmpPath := outputPath + ".m4a.tmp"
|
tmpPath := outputPath + ".m4a.tmp"
|
||||||
if _, err := os.Stat(tmpPath); err == nil {
|
if _, err := os.Stat(tmpPath); err == nil {
|
||||||
fmt.Printf("[Tidal] Cleaning up leftover temp file: %s\n", tmpPath)
|
GoLog("[Tidal] Cleaning up leftover temp file: %s\n", tmpPath)
|
||||||
os.Remove(tmpPath)
|
os.Remove(tmpPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1311,7 +1674,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
if quality == "" {
|
if quality == "" {
|
||||||
quality = "LOSSLESS"
|
quality = "LOSSLESS"
|
||||||
}
|
}
|
||||||
fmt.Printf("[Tidal] Using quality: %s\n", quality)
|
GoLog("[Tidal] Using quality: %s\n", quality)
|
||||||
|
|
||||||
// Get download URL using parallel API requests
|
// Get download URL using parallel API requests
|
||||||
downloadInfo, err := downloader.GetDownloadURL(track.ID, quality)
|
downloadInfo, err := downloader.GetDownloadURL(track.ID, quality)
|
||||||
@@ -1320,7 +1683,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Log actual quality received
|
// Log actual quality received
|
||||||
fmt.Printf("[Tidal] Actual quality: %d-bit/%dHz\n", downloadInfo.BitDepth, downloadInfo.SampleRate)
|
GoLog("[Tidal] Actual quality: %d-bit/%dHz\n", downloadInfo.BitDepth, downloadInfo.SampleRate)
|
||||||
|
|
||||||
// START PARALLEL: Fetch cover and lyrics while downloading audio
|
// START PARALLEL: Fetch cover and lyrics while downloading audio
|
||||||
var parallelResult *ParallelDownloadResult
|
var parallelResult *ParallelDownloadResult
|
||||||
@@ -1338,8 +1701,8 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
// Download audio file with item ID for progress tracking
|
// Download audio file with item ID for progress tracking
|
||||||
fmt.Printf("[Tidal] Starting download to: %s\n", outputPath)
|
GoLog("[Tidal] Starting download to: %s\n", outputPath)
|
||||||
fmt.Printf("[Tidal] Download URL type: %s\n", func() string {
|
GoLog("[Tidal] Download URL type: %s\n", func() string {
|
||||||
if strings.HasPrefix(downloadInfo.URL, "MANIFEST:") {
|
if strings.HasPrefix(downloadInfo.URL, "MANIFEST:") {
|
||||||
return "MANIFEST (DASH/BTS)"
|
return "MANIFEST (DASH/BTS)"
|
||||||
}
|
}
|
||||||
@@ -1347,7 +1710,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
}())
|
}())
|
||||||
|
|
||||||
if err := downloader.DownloadFile(downloadInfo.URL, outputPath, req.ItemID); err != nil {
|
if err := downloader.DownloadFile(downloadInfo.URL, outputPath, req.ItemID); err != nil {
|
||||||
fmt.Printf("[Tidal] Download failed with error: %v\n", err)
|
GoLog("[Tidal] Download failed with error: %v\n", err)
|
||||||
return TidalDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
return TidalDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
||||||
}
|
}
|
||||||
fmt.Println("[Tidal] Download completed successfully")
|
fmt.Println("[Tidal] Download completed successfully")
|
||||||
@@ -1368,7 +1731,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
if _, err := os.Stat(m4aPath); err == nil {
|
if _, err := os.Stat(m4aPath); err == nil {
|
||||||
// File was saved as M4A, use that path
|
// File was saved as M4A, use that path
|
||||||
actualOutputPath = m4aPath
|
actualOutputPath = m4aPath
|
||||||
fmt.Printf("[Tidal] File saved as M4A (DASH stream): %s\n", actualOutputPath)
|
GoLog("[Tidal] File saved as M4A (DASH stream): %s\n", actualOutputPath)
|
||||||
} else if _, err := os.Stat(outputPath); err != nil {
|
} else if _, err := os.Stat(outputPath); err != nil {
|
||||||
// Neither FLAC nor M4A exists
|
// Neither FLAC nor M4A exists
|
||||||
return TidalDownloadResult{}, fmt.Errorf("download completed but file not found at %s or %s", outputPath, m4aPath)
|
return TidalDownloadResult{}, fmt.Errorf("download completed but file not found at %s or %s", outputPath, m4aPath)
|
||||||
@@ -1381,17 +1744,17 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
Album: req.AlbumName,
|
Album: req.AlbumName,
|
||||||
AlbumArtist: req.AlbumArtist,
|
AlbumArtist: req.AlbumArtist,
|
||||||
Date: req.ReleaseDate,
|
Date: req.ReleaseDate,
|
||||||
TrackNumber: req.TrackNumber,
|
TrackNumber: track.TrackNumber, // Use actual track number from Tidal
|
||||||
TotalTracks: req.TotalTracks,
|
TotalTracks: req.TotalTracks,
|
||||||
DiscNumber: req.DiscNumber,
|
DiscNumber: track.VolumeNumber, // Use actual disc number from Tidal
|
||||||
ISRC: req.ISRC,
|
ISRC: track.ISRC, // Use actual ISRC from Tidal
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use cover data from parallel fetch
|
// Use cover data from parallel fetch
|
||||||
var coverData []byte
|
var coverData []byte
|
||||||
if parallelResult != nil && parallelResult.CoverData != nil {
|
if parallelResult != nil && parallelResult.CoverData != nil {
|
||||||
coverData = parallelResult.CoverData
|
coverData = parallelResult.CoverData
|
||||||
fmt.Printf("[Tidal] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
GoLog("[Tidal] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Embed metadata based on file type
|
// Embed metadata based on file type
|
||||||
@@ -1402,9 +1765,9 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
|
|
||||||
// Embed lyrics from parallel fetch
|
// Embed lyrics from parallel fetch
|
||||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||||
fmt.Printf("[Tidal] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
GoLog("[Tidal] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
||||||
if embedErr := EmbedLyrics(actualOutputPath, parallelResult.LyricsLRC); embedErr != nil {
|
if embedErr := EmbedLyrics(actualOutputPath, parallelResult.LyricsLRC); embedErr != nil {
|
||||||
fmt.Printf("[Tidal] Warning: failed to embed lyrics: %v\n", embedErr)
|
GoLog("[Tidal] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||||
} else {
|
} else {
|
||||||
fmt.Println("[Tidal] Lyrics embedded successfully")
|
fmt.Println("[Tidal] Lyrics embedded successfully")
|
||||||
}
|
}
|
||||||
@@ -1413,7 +1776,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
} else if strings.HasSuffix(actualOutputPath, ".m4a") {
|
} else if strings.HasSuffix(actualOutputPath, ".m4a") {
|
||||||
// Embed metadata to M4A file
|
// Embed metadata to M4A file
|
||||||
// fmt.Printf("[Tidal] Embedding metadata to M4A file...\n")
|
// GoLog("[Tidal] Embedding metadata to M4A file...\n")
|
||||||
|
|
||||||
// Add lyrics to metadata if available
|
// Add lyrics to metadata if available
|
||||||
// if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
// if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||||
@@ -1427,7 +1790,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
fmt.Println("[Tidal] Skipping metadata embedding for M4A file (will be handled after FFmpeg conversion)")
|
fmt.Println("[Tidal] Skipping metadata embedding for M4A file (will be handled after FFmpeg conversion)")
|
||||||
|
|
||||||
// if err := EmbedM4AMetadata(actualOutputPath, metadata, coverData); err != nil {
|
// if err := EmbedM4AMetadata(actualOutputPath, metadata, coverData); err != nil {
|
||||||
// fmt.Printf("[Tidal] Warning: failed to embed M4A metadata: %v\n", err)
|
// GoLog("[Tidal] Warning: failed to embed M4A metadata: %v\n", err)
|
||||||
// } else {
|
// } else {
|
||||||
// fmt.Println("[Tidal] M4A metadata embedded successfully")
|
// fmt.Println("[Tidal] M4A metadata embedded successfully")
|
||||||
// }
|
// }
|
||||||
@@ -1446,5 +1809,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
ReleaseDate: track.Album.ReleaseDate,
|
ReleaseDate: track.Album.ReleaseDate,
|
||||||
TrackNumber: track.TrackNumber,
|
TrackNumber: track.TrackNumber,
|
||||||
DiscNumber: track.VolumeNumber,
|
DiscNumber: track.VolumeNumber,
|
||||||
|
ISRC: track.ISRC,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,6 +181,13 @@ import Gobackend // Import Go framework
|
|||||||
GobackendCleanupConnections()
|
GobackendCleanupConnections()
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
|
case "readFileMetadata":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let filePath = args["file_path"] as! String
|
||||||
|
let response = GobackendReadFileMetadata(filePath, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
case "searchDeezerAll":
|
case "searchDeezerAll":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let query = args["query"] as! String
|
let query = args["query"] as! String
|
||||||
@@ -242,6 +249,38 @@ import Gobackend // Import Go framework
|
|||||||
GobackendClearTrackCache()
|
GobackendClearTrackCache()
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
|
case "setSpotifyCredentials":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let clientId = args["client_id"] as! String
|
||||||
|
let clientSecret = args["client_secret"] as! String
|
||||||
|
GobackendSetSpotifyAPICredentials(clientId, clientSecret)
|
||||||
|
return nil
|
||||||
|
|
||||||
|
// Log methods
|
||||||
|
case "getLogs":
|
||||||
|
let response = GobackendGetLogs()
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "getLogsSince":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let index = args["index"] as? Int ?? 0
|
||||||
|
let response = GobackendGetLogsSince(Int(index))
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "clearLogs":
|
||||||
|
GobackendClearLogs()
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case "getLogCount":
|
||||||
|
let response = GobackendGetLogCount()
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "setLoggingEnabled":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let enabled = args["enabled"] as? Bool ?? false
|
||||||
|
GobackendSetLoggingEnabled(enabled)
|
||||||
|
return nil
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw NSError(
|
throw NSError(
|
||||||
domain: "SpotiFLAC",
|
domain: "SpotiFLAC",
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/// App version and info constants
|
/// App version and info constants
|
||||||
/// Update version here only - all other files will reference this
|
/// Update version here only - all other files will reference this
|
||||||
class AppInfo {
|
class AppInfo {
|
||||||
static const String version = '2.1.7';
|
static const String version = '2.2.7';
|
||||||
static const String buildNumber = '45';
|
static const String buildNumber = '49';
|
||||||
static const String fullVersion = '$version+$buildNumber';
|
static const String fullVersion = '$version+$buildNumber';
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -23,9 +23,10 @@ class AppSettings {
|
|||||||
final String spotifyClientSecret; // Custom Spotify client secret (empty = use default)
|
final String spotifyClientSecret; // Custom Spotify client secret (empty = use default)
|
||||||
final bool useCustomSpotifyCredentials; // Whether to use custom credentials (if set)
|
final bool useCustomSpotifyCredentials; // Whether to use custom credentials (if set)
|
||||||
final String metadataSource; // spotify, deezer - source for search and metadata
|
final String metadataSource; // spotify, deezer - source for search and metadata
|
||||||
|
final bool enableLogging; // Enable detailed logging for debugging
|
||||||
|
|
||||||
const AppSettings({
|
const AppSettings({
|
||||||
this.defaultService = 'qobuz',
|
this.defaultService = 'tidal',
|
||||||
this.audioQuality = 'LOSSLESS',
|
this.audioQuality = 'LOSSLESS',
|
||||||
this.filenameFormat = '{title} - {artist}',
|
this.filenameFormat = '{title} - {artist}',
|
||||||
this.downloadDirectory = '',
|
this.downloadDirectory = '',
|
||||||
@@ -44,6 +45,7 @@ class AppSettings {
|
|||||||
this.spotifyClientSecret = '', // Default: use built-in credentials
|
this.spotifyClientSecret = '', // Default: use built-in credentials
|
||||||
this.useCustomSpotifyCredentials = true, // Default: use custom if set
|
this.useCustomSpotifyCredentials = true, // Default: use custom if set
|
||||||
this.metadataSource = 'deezer', // Default: Deezer (no rate limit)
|
this.metadataSource = 'deezer', // Default: Deezer (no rate limit)
|
||||||
|
this.enableLogging = false, // Default: disabled for performance
|
||||||
});
|
});
|
||||||
|
|
||||||
AppSettings copyWith({
|
AppSettings copyWith({
|
||||||
@@ -66,6 +68,7 @@ class AppSettings {
|
|||||||
String? spotifyClientSecret,
|
String? spotifyClientSecret,
|
||||||
bool? useCustomSpotifyCredentials,
|
bool? useCustomSpotifyCredentials,
|
||||||
String? metadataSource,
|
String? metadataSource,
|
||||||
|
bool? enableLogging,
|
||||||
}) {
|
}) {
|
||||||
return AppSettings(
|
return AppSettings(
|
||||||
defaultService: defaultService ?? this.defaultService,
|
defaultService: defaultService ?? this.defaultService,
|
||||||
@@ -87,6 +90,7 @@ class AppSettings {
|
|||||||
spotifyClientSecret: spotifyClientSecret ?? this.spotifyClientSecret,
|
spotifyClientSecret: spotifyClientSecret ?? this.spotifyClientSecret,
|
||||||
useCustomSpotifyCredentials: useCustomSpotifyCredentials ?? this.useCustomSpotifyCredentials,
|
useCustomSpotifyCredentials: useCustomSpotifyCredentials ?? this.useCustomSpotifyCredentials,
|
||||||
metadataSource: metadataSource ?? this.metadataSource,
|
metadataSource: metadataSource ?? this.metadataSource,
|
||||||
|
enableLogging: enableLogging ?? this.enableLogging,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
|||||||
useCustomSpotifyCredentials:
|
useCustomSpotifyCredentials:
|
||||||
json['useCustomSpotifyCredentials'] as bool? ?? true,
|
json['useCustomSpotifyCredentials'] as bool? ?? true,
|
||||||
metadataSource: json['metadataSource'] as String? ?? 'deezer',
|
metadataSource: json['metadataSource'] as String? ?? 'deezer',
|
||||||
|
enableLogging: json['enableLogging'] as bool? ?? false,
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||||
@@ -50,4 +51,5 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
|||||||
'spotifyClientSecret': instance.spotifyClientSecret,
|
'spotifyClientSecret': instance.spotifyClientSecret,
|
||||||
'useCustomSpotifyCredentials': instance.useCustomSpotifyCredentials,
|
'useCustomSpotifyCredentials': instance.useCustomSpotifyCredentials,
|
||||||
'metadataSource': instance.metadataSource,
|
'metadataSource': instance.metadataSource,
|
||||||
|
'enableLogging': instance.enableLogging,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:math';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
@@ -35,6 +36,9 @@ class DownloadHistoryItem {
|
|||||||
final int? duration;
|
final int? duration;
|
||||||
final String? releaseDate;
|
final String? releaseDate;
|
||||||
final String? quality;
|
final String? quality;
|
||||||
|
// Audio quality info (from file after download)
|
||||||
|
final int? bitDepth;
|
||||||
|
final int? sampleRate;
|
||||||
|
|
||||||
const DownloadHistoryItem({
|
const DownloadHistoryItem({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -53,6 +57,8 @@ class DownloadHistoryItem {
|
|||||||
this.duration,
|
this.duration,
|
||||||
this.releaseDate,
|
this.releaseDate,
|
||||||
this.quality,
|
this.quality,
|
||||||
|
this.bitDepth,
|
||||||
|
this.sampleRate,
|
||||||
});
|
});
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
@@ -72,26 +78,31 @@ class DownloadHistoryItem {
|
|||||||
'duration': duration,
|
'duration': duration,
|
||||||
'releaseDate': releaseDate,
|
'releaseDate': releaseDate,
|
||||||
'quality': quality,
|
'quality': quality,
|
||||||
|
'bitDepth': bitDepth,
|
||||||
|
'sampleRate': sampleRate,
|
||||||
};
|
};
|
||||||
|
|
||||||
factory DownloadHistoryItem.fromJson(Map<String, dynamic> json) => DownloadHistoryItem(
|
factory DownloadHistoryItem.fromJson(Map<String, dynamic> json) =>
|
||||||
id: json['id'] as String,
|
DownloadHistoryItem(
|
||||||
trackName: json['trackName'] as String,
|
id: json['id'] as String,
|
||||||
artistName: json['artistName'] as String,
|
trackName: json['trackName'] as String,
|
||||||
albumName: json['albumName'] as String,
|
artistName: json['artistName'] as String,
|
||||||
albumArtist: json['albumArtist'] as String?,
|
albumName: json['albumName'] as String,
|
||||||
coverUrl: json['coverUrl'] as String?,
|
albumArtist: json['albumArtist'] as String?,
|
||||||
filePath: json['filePath'] as String,
|
coverUrl: json['coverUrl'] as String?,
|
||||||
service: json['service'] as String,
|
filePath: json['filePath'] as String,
|
||||||
downloadedAt: DateTime.parse(json['downloadedAt'] as String),
|
service: json['service'] as String,
|
||||||
isrc: json['isrc'] as String?,
|
downloadedAt: DateTime.parse(json['downloadedAt'] as String),
|
||||||
spotifyId: json['spotifyId'] as String?,
|
isrc: json['isrc'] as String?,
|
||||||
trackNumber: json['trackNumber'] as int?,
|
spotifyId: json['spotifyId'] as String?,
|
||||||
discNumber: json['discNumber'] as int?,
|
trackNumber: json['trackNumber'] as int?,
|
||||||
duration: json['duration'] as int?,
|
discNumber: json['discNumber'] as int?,
|
||||||
releaseDate: json['releaseDate'] as String?,
|
duration: json['duration'] as int?,
|
||||||
quality: json['quality'] as String?,
|
releaseDate: json['releaseDate'] as String?,
|
||||||
);
|
quality: json['quality'] as String?,
|
||||||
|
bitDepth: json['bitDepth'] as int?,
|
||||||
|
sampleRate: json['sampleRate'] as int?,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download History State
|
// Download History State
|
||||||
@@ -100,13 +111,14 @@ class DownloadHistoryState {
|
|||||||
final Set<String> _downloadedSpotifyIds; // Cache for O(1) lookup
|
final Set<String> _downloadedSpotifyIds; // Cache for O(1) lookup
|
||||||
|
|
||||||
DownloadHistoryState({this.items = const []})
|
DownloadHistoryState({this.items = const []})
|
||||||
: _downloadedSpotifyIds = items
|
: _downloadedSpotifyIds = items
|
||||||
.where((item) => item.spotifyId != null && item.spotifyId!.isNotEmpty)
|
.where((item) => item.spotifyId != null && item.spotifyId!.isNotEmpty)
|
||||||
.map((item) => item.spotifyId!)
|
.map((item) => item.spotifyId!)
|
||||||
.toSet();
|
.toSet();
|
||||||
|
|
||||||
/// Check if a track has been downloaded (by Spotify ID)
|
/// Check if a track has been downloaded (by Spotify ID)
|
||||||
bool isDownloaded(String spotifyId) => _downloadedSpotifyIds.contains(spotifyId);
|
bool isDownloaded(String spotifyId) =>
|
||||||
|
_downloadedSpotifyIds.contains(spotifyId);
|
||||||
|
|
||||||
DownloadHistoryState copyWith({List<DownloadHistoryItem>? items}) {
|
DownloadHistoryState copyWith({List<DownloadHistoryItem>? items}) {
|
||||||
return DownloadHistoryState(items: items ?? this.items);
|
return DownloadHistoryState(items: items ?? this.items);
|
||||||
@@ -140,7 +152,9 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
|||||||
final jsonStr = prefs.getString(_storageKey);
|
final jsonStr = prefs.getString(_storageKey);
|
||||||
if (jsonStr != null && jsonStr.isNotEmpty) {
|
if (jsonStr != null && jsonStr.isNotEmpty) {
|
||||||
final List<dynamic> jsonList = jsonDecode(jsonStr);
|
final List<dynamic> jsonList = jsonDecode(jsonStr);
|
||||||
final items = jsonList.map((e) => DownloadHistoryItem.fromJson(e as Map<String, dynamic>)).toList();
|
final items = jsonList
|
||||||
|
.map((e) => DownloadHistoryItem.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
state = state.copyWith(items: items);
|
state = state.copyWith(items: items);
|
||||||
_historyLog.i('Loaded ${items.length} items from storage');
|
_historyLog.i('Loaded ${items.length} items from storage');
|
||||||
} else {
|
} else {
|
||||||
@@ -200,9 +214,10 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Download History Provider
|
// Download History Provider
|
||||||
final downloadHistoryProvider = NotifierProvider<DownloadHistoryNotifier, DownloadHistoryState>(
|
final downloadHistoryProvider =
|
||||||
DownloadHistoryNotifier.new,
|
NotifierProvider<DownloadHistoryNotifier, DownloadHistoryState>(
|
||||||
);
|
DownloadHistoryNotifier.new,
|
||||||
|
);
|
||||||
|
|
||||||
class DownloadQueueState {
|
class DownloadQueueState {
|
||||||
final List<DownloadItem> items;
|
final List<DownloadItem> items;
|
||||||
@@ -251,10 +266,19 @@ class DownloadQueueState {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
int get queuedCount => items.where((i) => i.status == DownloadStatus.queued || i.status == DownloadStatus.downloading).length;
|
int get queuedCount => items
|
||||||
int get completedCount => items.where((i) => i.status == DownloadStatus.completed).length;
|
.where(
|
||||||
int get failedCount => items.where((i) => i.status == DownloadStatus.failed).length;
|
(i) =>
|
||||||
int get activeDownloadsCount => items.where((i) => i.status == DownloadStatus.downloading).length;
|
i.status == DownloadStatus.queued ||
|
||||||
|
i.status == DownloadStatus.downloading,
|
||||||
|
)
|
||||||
|
.length;
|
||||||
|
int get completedCount =>
|
||||||
|
items.where((i) => i.status == DownloadStatus.completed).length;
|
||||||
|
int get failedCount =>
|
||||||
|
items.where((i) => i.status == DownloadStatus.failed).length;
|
||||||
|
int get activeDownloadsCount =>
|
||||||
|
items.where((i) => i.status == DownloadStatus.downloading).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download Queue Notifier (Riverpod 3.x)
|
// Download Queue Notifier (Riverpod 3.x)
|
||||||
@@ -262,7 +286,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
Timer? _progressTimer;
|
Timer? _progressTimer;
|
||||||
int _downloadCount = 0; // Counter for connection cleanup
|
int _downloadCount = 0; // Counter for connection cleanup
|
||||||
static const _cleanupInterval = 50; // Cleanup every 50 downloads
|
static const _cleanupInterval = 50; // Cleanup every 50 downloads
|
||||||
static const _queueStorageKey = 'download_queue'; // Storage key for queue persistence
|
static const _queueStorageKey =
|
||||||
|
'download_queue'; // Storage key for queue persistence
|
||||||
final NotificationService _notificationService = NotificationService();
|
final NotificationService _notificationService = NotificationService();
|
||||||
int _totalQueuedAtStart = 0; // Track total items when queue started
|
int _totalQueuedAtStart = 0; // Track total items when queue started
|
||||||
int _completedInSession = 0; // Track completed downloads in current session
|
int _completedInSession = 0; // Track completed downloads in current session
|
||||||
@@ -295,7 +320,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
final jsonStr = prefs.getString(_queueStorageKey);
|
final jsonStr = prefs.getString(_queueStorageKey);
|
||||||
if (jsonStr != null && jsonStr.isNotEmpty) {
|
if (jsonStr != null && jsonStr.isNotEmpty) {
|
||||||
final List<dynamic> jsonList = jsonDecode(jsonStr);
|
final List<dynamic> jsonList = jsonDecode(jsonStr);
|
||||||
final items = jsonList.map((e) => DownloadItem.fromJson(e as Map<String, dynamic>)).toList();
|
final items = jsonList
|
||||||
|
.map((e) => DownloadItem.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
|
||||||
// Reset downloading items to queued (they were interrupted)
|
// Reset downloading items to queued (they were interrupted)
|
||||||
final restoredItems = items.map((item) {
|
final restoredItems = items.map((item) {
|
||||||
@@ -306,9 +333,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
// Only restore queued/downloading items (not completed/failed/skipped)
|
// Only restore queued/downloading items (not completed/failed/skipped)
|
||||||
final pendingItems = restoredItems.where((item) =>
|
final pendingItems = restoredItems
|
||||||
item.status == DownloadStatus.queued
|
.where((item) => item.status == DownloadStatus.queued)
|
||||||
).toList();
|
.toList();
|
||||||
|
|
||||||
if (pendingItems.isNotEmpty) {
|
if (pendingItems.isNotEmpty) {
|
||||||
state = state.copyWith(items: pendingItems);
|
state = state.copyWith(items: pendingItems);
|
||||||
@@ -335,10 +362,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
|
||||||
// Only persist queued and downloading items
|
// Only persist queued and downloading items
|
||||||
final pendingItems = state.items.where((item) =>
|
final pendingItems = state.items
|
||||||
item.status == DownloadStatus.queued ||
|
.where(
|
||||||
item.status == DownloadStatus.downloading
|
(item) =>
|
||||||
).toList();
|
item.status == DownloadStatus.queued ||
|
||||||
|
item.status == DownloadStatus.downloading,
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
|
||||||
if (pendingItems.isEmpty) {
|
if (pendingItems.isEmpty) {
|
||||||
// Clear storage if no pending items
|
// Clear storage if no pending items
|
||||||
@@ -357,7 +387,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
/// Start multi-progress polling for all downloads (sequential and parallel)
|
/// Start multi-progress polling for all downloads (sequential and parallel)
|
||||||
void _startMultiProgressPolling() {
|
void _startMultiProgressPolling() {
|
||||||
_progressTimer?.cancel();
|
_progressTimer?.cancel();
|
||||||
_progressTimer = Timer.periodic(const Duration(milliseconds: 500), (timer) async {
|
_progressTimer = Timer.periodic(const Duration(milliseconds: 500), (
|
||||||
|
timer,
|
||||||
|
) async {
|
||||||
try {
|
try {
|
||||||
final allProgress = await PlatformBridge.getAllDownloadProgress();
|
final allProgress = await PlatformBridge.getAllDownloadProgress();
|
||||||
final items = allProgress['items'] as Map<String, dynamic>? ?? {};
|
final items = allProgress['items'] as Map<String, dynamic>? ?? {};
|
||||||
@@ -371,8 +403,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
final itemProgress = entry.value as Map<String, dynamic>;
|
final itemProgress = entry.value as Map<String, dynamic>;
|
||||||
final bytesReceived = itemProgress['bytes_received'] as int? ?? 0;
|
final bytesReceived = itemProgress['bytes_received'] as int? ?? 0;
|
||||||
final bytesTotal = itemProgress['bytes_total'] as int? ?? 0;
|
final bytesTotal = itemProgress['bytes_total'] as int? ?? 0;
|
||||||
final speedMBps = (itemProgress['speed_mbps'] as num?)?.toDouble() ?? 0.0;
|
final speedMBps =
|
||||||
final isDownloading = itemProgress['is_downloading'] as bool? ?? false;
|
(itemProgress['speed_mbps'] as num?)?.toDouble() ?? 0.0;
|
||||||
|
final isDownloading =
|
||||||
|
itemProgress['is_downloading'] as bool? ?? false;
|
||||||
final status = itemProgress['status'] as String? ?? 'downloading';
|
final status = itemProgress['status'] as String? ?? 'downloading';
|
||||||
|
|
||||||
// Check if status is "finalizing" (embedding metadata)
|
// Check if status is "finalizing" (embedding metadata)
|
||||||
@@ -381,7 +415,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
updateItemStatus(itemId, DownloadStatus.finalizing, progress: 1.0);
|
updateItemStatus(itemId, DownloadStatus.finalizing, progress: 1.0);
|
||||||
|
|
||||||
// Track finalizing item for notification
|
// Track finalizing item for notification
|
||||||
final currentItem = state.items.where((i) => i.id == itemId).firstOrNull;
|
final currentItem = state.items
|
||||||
|
.where((i) => i.id == itemId)
|
||||||
|
.firstOrNull;
|
||||||
if (currentItem != null) {
|
if (currentItem != null) {
|
||||||
hasFinalizingItem = true;
|
hasFinalizingItem = true;
|
||||||
finalizingTrackName = currentItem.track.name;
|
finalizingTrackName = currentItem.track.name;
|
||||||
@@ -391,16 +427,17 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Use progress from backend if available (handles both explicit progress and byte-based)
|
// Use progress from backend if available (handles both explicit progress and byte-based)
|
||||||
final progressFromBackend = (itemProgress['progress'] as num?)?.toDouble() ?? 0.0;
|
final progressFromBackend =
|
||||||
|
(itemProgress['progress'] as num?)?.toDouble() ?? 0.0;
|
||||||
|
|
||||||
if (isDownloading) {
|
if (isDownloading) {
|
||||||
double percentage = 0.0;
|
double percentage = 0.0;
|
||||||
if (bytesTotal > 0) {
|
if (bytesTotal > 0) {
|
||||||
// Calculate from bytes if available for precision
|
// Calculate from bytes if available for precision
|
||||||
percentage = bytesReceived / bytesTotal;
|
percentage = bytesReceived / bytesTotal;
|
||||||
} else {
|
} else {
|
||||||
// Fallback to backend-reported progress (e.g. for DASH segments)
|
// Fallback to backend-reported progress (e.g. for DASH segments)
|
||||||
percentage = progressFromBackend;
|
percentage = progressFromBackend;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateProgress(itemId, percentage, speedMBps: speedMBps);
|
updateProgress(itemId, percentage, speedMBps: speedMBps);
|
||||||
@@ -409,9 +446,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
final mbReceived = bytesReceived / (1024 * 1024);
|
final mbReceived = bytesReceived / (1024 * 1024);
|
||||||
final mbTotal = bytesTotal / (1024 * 1024);
|
final mbTotal = bytesTotal / (1024 * 1024);
|
||||||
if (bytesTotal > 0) {
|
if (bytesTotal > 0) {
|
||||||
_log.d('Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (${mbReceived.toStringAsFixed(2)}/${mbTotal.toStringAsFixed(2)} MB) @ ${speedMBps.toStringAsFixed(2)} MB/s');
|
_log.d(
|
||||||
|
'Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (${mbReceived.toStringAsFixed(2)}/${mbTotal.toStringAsFixed(2)} MB) @ ${speedMBps.toStringAsFixed(2)} MB/s',
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
_log.d('Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (DASH segments/unknown size) @ ${speedMBps.toStringAsFixed(2)} MB/s');
|
_log.d(
|
||||||
|
'Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (DASH segments/unknown size) @ ${speedMBps.toStringAsFixed(2)} MB/s',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -433,7 +474,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
final bytesTotal = firstProgress['bytes_total'] as int? ?? 0;
|
final bytesTotal = firstProgress['bytes_total'] as int? ?? 0;
|
||||||
|
|
||||||
// Find downloading items (not finalizing)
|
// Find downloading items (not finalizing)
|
||||||
final downloadingItems = state.items.where((i) => i.status == DownloadStatus.downloading).toList();
|
final downloadingItems = state.items
|
||||||
|
.where((i) => i.status == DownloadStatus.downloading)
|
||||||
|
.toList();
|
||||||
if (downloadingItems.isNotEmpty) {
|
if (downloadingItems.isNotEmpty) {
|
||||||
// Show single track name if only 1 download, otherwise show count
|
// Show single track name if only 1 download, otherwise show count
|
||||||
final trackName = downloadingItems.length == 1
|
final trackName = downloadingItems.length == 1
|
||||||
@@ -449,7 +492,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
|
|
||||||
if (bytesTotal <= 0) {
|
if (bytesTotal <= 0) {
|
||||||
// Fallback to percentage for DASH/unknown size
|
// Fallback to percentage for DASH/unknown size
|
||||||
final progressPercent = (firstProgress['progress'] as num?)?.toDouble() ?? 0.0;
|
final progressPercent =
|
||||||
|
(firstProgress['progress'] as num?)?.toDouble() ?? 0.0;
|
||||||
notifProgress = (progressPercent * 100).toInt();
|
notifProgress = (progressPercent * 100).toInt();
|
||||||
notifTotal = 100;
|
notifTotal = 100;
|
||||||
}
|
}
|
||||||
@@ -499,7 +543,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
// Android: Use external storage Music folder
|
// Android: Use external storage Music folder
|
||||||
final dir = await getExternalStorageDirectory();
|
final dir = await getExternalStorageDirectory();
|
||||||
if (dir != null) {
|
if (dir != null) {
|
||||||
final musicDir = Directory('${dir.parent.parent.parent.parent.path}/Music/SpotiFLAC');
|
final musicDir = Directory(
|
||||||
|
'${dir.parent.parent.parent.parent.path}/Music/SpotiFLAC',
|
||||||
|
);
|
||||||
if (!await musicDir.exists()) {
|
if (!await musicDir.exists()) {
|
||||||
await musicDir.create(recursive: true);
|
await musicDir.create(recursive: true);
|
||||||
}
|
}
|
||||||
@@ -578,7 +624,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
|
|
||||||
void updateSettings(AppSettings settings) {
|
void updateSettings(AppSettings settings) {
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
outputDir: settings.downloadDirectory.isNotEmpty ? settings.downloadDirectory : state.outputDir,
|
outputDir: settings.downloadDirectory.isNotEmpty
|
||||||
|
? settings.downloadDirectory
|
||||||
|
: state.outputDir,
|
||||||
filenameFormat: settings.filenameFormat,
|
filenameFormat: settings.filenameFormat,
|
||||||
audioQuality: settings.audioQuality,
|
audioQuality: settings.audioQuality,
|
||||||
autoFallback: settings.autoFallback,
|
autoFallback: settings.autoFallback,
|
||||||
@@ -591,7 +639,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
updateSettings(settings);
|
updateSettings(settings);
|
||||||
|
|
||||||
final id = '${track.isrc ?? track.id}-${DateTime.now().millisecondsSinceEpoch}';
|
final id =
|
||||||
|
'${track.isrc ?? track.id}-${DateTime.now().millisecondsSinceEpoch}';
|
||||||
final item = DownloadItem(
|
final item = DownloadItem(
|
||||||
id: id,
|
id: id,
|
||||||
track: track,
|
track: track,
|
||||||
@@ -611,13 +660,18 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
void addMultipleToQueue(List<Track> tracks, String service, {String? qualityOverride}) {
|
void addMultipleToQueue(
|
||||||
|
List<Track> tracks,
|
||||||
|
String service, {
|
||||||
|
String? qualityOverride,
|
||||||
|
}) {
|
||||||
// Sync settings before adding to queue
|
// Sync settings before adding to queue
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
updateSettings(settings);
|
updateSettings(settings);
|
||||||
|
|
||||||
final newItems = tracks.map((track) {
|
final newItems = tracks.map((track) {
|
||||||
final id = '${track.isrc ?? track.id}-${DateTime.now().millisecondsSinceEpoch}';
|
final id =
|
||||||
|
'${track.isrc ?? track.id}-${DateTime.now().millisecondsSinceEpoch}';
|
||||||
return DownloadItem(
|
return DownloadItem(
|
||||||
id: id,
|
id: id,
|
||||||
track: track,
|
track: track,
|
||||||
@@ -636,7 +690,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void updateItemStatus(String id, DownloadStatus status, {double? progress, double? speedMBps, String? filePath, String? error, DownloadErrorType? errorType}) {
|
void updateItemStatus(
|
||||||
|
String id,
|
||||||
|
DownloadStatus status, {
|
||||||
|
double? progress,
|
||||||
|
double? speedMBps,
|
||||||
|
String? filePath,
|
||||||
|
String? error,
|
||||||
|
DownloadErrorType? errorType,
|
||||||
|
}) {
|
||||||
final items = state.items.map((item) {
|
final items = state.items.map((item) {
|
||||||
if (item.id == id) {
|
if (item.id == id) {
|
||||||
return item.copyWith(
|
return item.copyWith(
|
||||||
@@ -662,7 +724,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void updateProgress(String id, double progress, {double? speedMBps}) {
|
void updateProgress(String id, double progress, {double? speedMBps}) {
|
||||||
updateItemStatus(id, DownloadStatus.downloading, progress: progress, speedMBps: speedMBps);
|
updateItemStatus(
|
||||||
|
id,
|
||||||
|
DownloadStatus.downloading,
|
||||||
|
progress: progress,
|
||||||
|
speedMBps: speedMBps,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void cancelItem(String id) {
|
void cancelItem(String id) {
|
||||||
@@ -670,11 +737,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void clearCompleted() {
|
void clearCompleted() {
|
||||||
final items = state.items.where((item) =>
|
final items = state.items
|
||||||
item.status != DownloadStatus.completed &&
|
.where(
|
||||||
item.status != DownloadStatus.failed &&
|
(item) =>
|
||||||
item.status != DownloadStatus.skipped
|
item.status != DownloadStatus.completed &&
|
||||||
).toList();
|
item.status != DownloadStatus.failed &&
|
||||||
|
item.status != DownloadStatus.skipped,
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
|
||||||
state = state.copyWith(items: items);
|
state = state.copyWith(items: items);
|
||||||
_saveQueueToStorage(); // Persist queue
|
_saveQueueToStorage(); // Persist queue
|
||||||
@@ -724,7 +794,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Only retry if status is failed or skipped
|
// Only retry if status is failed or skipped
|
||||||
if (item.status != DownloadStatus.failed && item.status != DownloadStatus.skipped) {
|
if (item.status != DownloadStatus.failed &&
|
||||||
|
item.status != DownloadStatus.skipped) {
|
||||||
_log.w('retryItem: Item status is ${item.status}, not retrying');
|
_log.w('retryItem: Item status is ${item.status}, not retrying');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -733,7 +804,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
|
|
||||||
final items = state.items.map((i) {
|
final items = state.items.map((i) {
|
||||||
if (i.id == id) {
|
if (i.id == id) {
|
||||||
return i.copyWith(status: DownloadStatus.queued, progress: 0, error: null);
|
return i.copyWith(
|
||||||
|
status: DownloadStatus.queued,
|
||||||
|
progress: 0,
|
||||||
|
error: null,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return i;
|
return i;
|
||||||
}).toList();
|
}).toList();
|
||||||
@@ -760,18 +835,20 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
Future<void> _embedMetadataAndCover(String flacPath, Track track) async {
|
Future<void> _embedMetadataAndCover(String flacPath, Track track) async {
|
||||||
// Download cover first
|
// Download cover first
|
||||||
String? coverPath;
|
String? coverPath;
|
||||||
if (track.coverUrl != null && track.coverUrl!.isNotEmpty) {
|
final coverUrl = track.coverUrl;
|
||||||
|
if (coverUrl != null && coverUrl.isNotEmpty) {
|
||||||
try {
|
try {
|
||||||
final tempDir = await getTemporaryDirectory();
|
final tempDir = await getTemporaryDirectory();
|
||||||
final uniqueId = DateTime.now().millisecondsSinceEpoch;
|
final uniqueId =
|
||||||
|
'${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(10000)}';
|
||||||
coverPath = '${tempDir.path}/cover_$uniqueId.jpg';
|
coverPath = '${tempDir.path}/cover_$uniqueId.jpg';
|
||||||
|
|
||||||
// Download cover using HTTP
|
// Download cover using HTTP
|
||||||
final httpClient = HttpClient();
|
final httpClient = HttpClient();
|
||||||
final request = await httpClient.getUrl(Uri.parse(track.coverUrl!));
|
final request = await httpClient.getUrl(Uri.parse(coverUrl));
|
||||||
final response = await request.close();
|
final response = await request.close();
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
final file = File(coverPath!);
|
final file = File(coverPath);
|
||||||
final sink = file.openWrite();
|
final sink = file.openWrite();
|
||||||
await response.pipe(sink);
|
await response.pipe(sink);
|
||||||
await sink.close();
|
await sink.close();
|
||||||
@@ -822,6 +899,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
metadata['ISRC'] = track.isrc!;
|
metadata['ISRC'] = track.isrc!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_log.d('Metadata map content: $metadata');
|
||||||
|
|
||||||
// Fetch Lyrics (Critical for M4A->FLAC conversion parity)
|
// Fetch Lyrics (Critical for M4A->FLAC conversion parity)
|
||||||
// Since we are in the Flutter context, we can call the bridge to get lyrics
|
// Since we are in the Flutter context, we can call the bridge to get lyrics
|
||||||
// This ensures even converted files have lyrics embedded if available
|
// This ensures even converted files have lyrics embedded if available
|
||||||
@@ -833,13 +912,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
filePath: '', // No local file path yet (processed in memory)
|
filePath: '', // No local file path yet (processed in memory)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (lrcContent != null && lrcContent.isNotEmpty) {
|
if (lrcContent.isNotEmpty) {
|
||||||
metadata['LYRICS'] = lrcContent;
|
metadata['LYRICS'] = lrcContent;
|
||||||
metadata['UNSYNCEDLYRICS'] = lrcContent; // Fallback for some players
|
metadata['UNSYNCEDLYRICS'] = lrcContent; // Fallback for some players
|
||||||
_log.d('Lyrics fetched for embedding (${lrcContent.length} chars)');
|
_log.d('Lyrics fetched for embedding (${lrcContent.length} chars)');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_log.w('Failed to fetch lyrics for embedding: $e');
|
_log.w('Failed to fetch lyrics for embedding: $e');
|
||||||
}
|
}
|
||||||
|
|
||||||
_log.d('Generating tags for FLAC: $metadata');
|
_log.d('Generating tags for FLAC: $metadata');
|
||||||
@@ -848,7 +927,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
// Note: FFmpegService.embedMetadata handles safe temp file creation
|
// Note: FFmpegService.embedMetadata handles safe temp file creation
|
||||||
final result = await FFmpegService.embedMetadata(
|
final result = await FFmpegService.embedMetadata(
|
||||||
flacPath: flacPath,
|
flacPath: flacPath,
|
||||||
coverPath: coverPath != null && await File(coverPath).exists() ? coverPath : null,
|
coverPath: coverPath != null && await File(coverPath).exists()
|
||||||
|
? coverPath
|
||||||
|
: null,
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -863,9 +944,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
try {
|
try {
|
||||||
final coverFile = File(coverPath);
|
final coverFile = File(coverPath);
|
||||||
if (await coverFile.exists()) {
|
if (await coverFile.exists()) {
|
||||||
// In Android 10+ scoped storage, we can't easily delete if we didn't create it
|
// In Android 10+ scoped storage, we can't easily delete if we didn't create it
|
||||||
// in this session or if it's not in our app dir.
|
// in this session or if it's not in our app dir.
|
||||||
// But coverPath is typically in temp dir now.
|
// But coverPath is typically in temp dir now.
|
||||||
await coverFile.delete();
|
await coverFile.delete();
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
@@ -882,7 +963,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
_log.i('Starting queue processing...');
|
_log.i('Starting queue processing...');
|
||||||
|
|
||||||
// Track total items at start for notification
|
// Track total items at start for notification
|
||||||
_totalQueuedAtStart = state.items.where((i) => i.status == DownloadStatus.queued).length;
|
_totalQueuedAtStart = state.items
|
||||||
|
.where((i) => i.status == DownloadStatus.queued)
|
||||||
|
.length;
|
||||||
_completedInSession = 0;
|
_completedInSession = 0;
|
||||||
_failedInSession = 0;
|
_failedInSession = 0;
|
||||||
|
|
||||||
@@ -955,7 +1038,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Show queue completion notification
|
// Show queue completion notification
|
||||||
_log.i('Queue stats - completed: $_completedInSession, failed: $_failedInSession, totalAtStart: $_totalQueuedAtStart');
|
_log.i(
|
||||||
|
'Queue stats - completed: $_completedInSession, failed: $_failedInSession, totalAtStart: $_totalQueuedAtStart',
|
||||||
|
);
|
||||||
if (_totalQueuedAtStart > 0) {
|
if (_totalQueuedAtStart > 0) {
|
||||||
await _notificationService.showQueueComplete(
|
await _notificationService.showQueueComplete(
|
||||||
completedCount: _completedInSession,
|
completedCount: _completedInSession,
|
||||||
@@ -967,9 +1052,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
state = state.copyWith(isProcessing: false, currentDownload: null);
|
state = state.copyWith(isProcessing: false, currentDownload: null);
|
||||||
|
|
||||||
// Check if there are new queued items (e.g., from retry) and restart if needed
|
// Check if there are new queued items (e.g., from retry) and restart if needed
|
||||||
final hasQueuedItems = state.items.any((item) => item.status == DownloadStatus.queued);
|
final hasQueuedItems = state.items.any(
|
||||||
|
(item) => item.status == DownloadStatus.queued,
|
||||||
|
);
|
||||||
if (hasQueuedItems) {
|
if (hasQueuedItems) {
|
||||||
_log.i('Found queued items after processing finished, restarting queue...');
|
_log.i(
|
||||||
|
'Found queued items after processing finished, restarting queue...',
|
||||||
|
);
|
||||||
Future.microtask(() => _processQueue());
|
Future.microtask(() => _processQueue());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -993,18 +1082,28 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
(item) => item.status == DownloadStatus.queued,
|
(item) => item.status == DownloadStatus.queued,
|
||||||
orElse: () => DownloadItem(
|
orElse: () => DownloadItem(
|
||||||
id: '',
|
id: '',
|
||||||
track: const Track(id: '', name: '', artistName: '', albumName: '', duration: 0),
|
track: const Track(
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
artistName: '',
|
||||||
|
albumName: '',
|
||||||
|
duration: 0,
|
||||||
|
),
|
||||||
service: '',
|
service: '',
|
||||||
createdAt: DateTime.now(),
|
createdAt: DateTime.now(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (nextItem.id.isEmpty) {
|
if (nextItem.id.isEmpty) {
|
||||||
_log.d('No more items to process (checked ${currentItems.length} items)');
|
_log.d(
|
||||||
|
'No more items to process (checked ${currentItems.length} items)',
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
_log.d('Processing next item: ${nextItem.track.name} (id: ${nextItem.id})');
|
_log.d(
|
||||||
|
'Processing next item: ${nextItem.track.name} (id: ${nextItem.id})',
|
||||||
|
);
|
||||||
await _downloadSingleItem(nextItem);
|
await _downloadSingleItem(nextItem);
|
||||||
|
|
||||||
// Clear item progress after download completes
|
// Clear item progress after download completes
|
||||||
@@ -1036,7 +1135,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get queued items
|
// Get queued items
|
||||||
final queuedItems = state.items.where((item) => item.status == DownloadStatus.queued).toList();
|
final queuedItems = state.items
|
||||||
|
.where((item) => item.status == DownloadStatus.queued)
|
||||||
|
.toList();
|
||||||
|
|
||||||
if (queuedItems.isEmpty && activeDownloads.isEmpty) {
|
if (queuedItems.isEmpty && activeDownloads.isEmpty) {
|
||||||
_log.d('No more items to process');
|
_log.d('No more items to process');
|
||||||
@@ -1044,7 +1145,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Start new downloads up to max concurrent limit
|
// Start new downloads up to max concurrent limit
|
||||||
while (activeDownloads.length < maxConcurrent && queuedItems.isNotEmpty && !state.isPaused) {
|
while (activeDownloads.length < maxConcurrent &&
|
||||||
|
queuedItems.isNotEmpty &&
|
||||||
|
!state.isPaused) {
|
||||||
final item = queuedItems.removeAt(0);
|
final item = queuedItems.removeAt(0);
|
||||||
|
|
||||||
// Mark as downloading immediately to prevent double-processing
|
// Mark as downloading immediately to prevent double-processing
|
||||||
@@ -1058,7 +1161,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
activeDownloads[item.id] = future;
|
activeDownloads[item.id] = future;
|
||||||
_log.d('Started parallel download: ${item.track.name} (${activeDownloads.length}/$maxConcurrent active)');
|
_log.d(
|
||||||
|
'Started parallel download: ${item.track.name} (${activeDownloads.length}/$maxConcurrent active)',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for at least one download to complete before checking for more
|
// Wait for at least one download to complete before checking for more
|
||||||
@@ -1094,44 +1199,83 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
// If track number is missing/0 (common from Search results), fetch full metadata
|
// If track number is missing/0 (common from Search results), fetch full metadata
|
||||||
// This ensures the downloaded file has correct tags (Track, Disc, Year)
|
// This ensures the downloaded file has correct tags (Track, Disc, Year)
|
||||||
Track trackToDownload = item.track;
|
Track trackToDownload = item.track;
|
||||||
if (trackToDownload.trackNumber == null || trackToDownload.trackNumber == 0) {
|
// Enrich metadata if ISRC or track number is missing (common from Search results)
|
||||||
|
// ISRC is critical for accurate track matching on streaming services
|
||||||
|
final needsEnrichment =
|
||||||
|
trackToDownload.id.startsWith('deezer:') &&
|
||||||
|
(trackToDownload.isrc == null ||
|
||||||
|
trackToDownload.isrc!.isEmpty ||
|
||||||
|
trackToDownload.trackNumber == null ||
|
||||||
|
trackToDownload.trackNumber == 0);
|
||||||
|
|
||||||
|
if (needsEnrichment) {
|
||||||
try {
|
try {
|
||||||
if (trackToDownload.id.startsWith('deezer:')) {
|
_log.d(
|
||||||
_log.d('Enriching incomplete metadata for Deezer track: ${trackToDownload.name}');
|
'Enriching incomplete metadata for Deezer track: ${trackToDownload.name}',
|
||||||
final rawId = trackToDownload.id.split(':')[1];
|
);
|
||||||
final fullData = await PlatformBridge.getDeezerMetadata('track', rawId);
|
_log.d(
|
||||||
|
'Current ISRC: ${trackToDownload.isrc}, TrackNumber: ${trackToDownload.trackNumber}',
|
||||||
|
);
|
||||||
|
final rawId = trackToDownload.id.split(':')[1];
|
||||||
|
_log.d('Fetching full metadata for Deezer ID: $rawId');
|
||||||
|
final fullData = await PlatformBridge.getDeezerMetadata(
|
||||||
|
'track',
|
||||||
|
rawId,
|
||||||
|
);
|
||||||
|
_log.d('Got response keys: ${fullData.keys.toList()}');
|
||||||
|
|
||||||
if (fullData.containsKey('track')) {
|
if (fullData.containsKey('track')) {
|
||||||
final fullTrack = Track.fromJson(fullData['track'] as Map<String, dynamic>);
|
// Parse Go backend response (snake_case) to Track
|
||||||
// Merge with existing (keep override quality/service if any, but update metadata)
|
final trackData = fullData['track'];
|
||||||
trackToDownload = Track(
|
_log.d('Track data type: ${trackData.runtimeType}');
|
||||||
id: fullTrack.id.isNotEmpty ? fullTrack.id : trackToDownload.id,
|
if (trackData is Map<String, dynamic>) {
|
||||||
name: fullTrack.name,
|
final data = trackData;
|
||||||
artistName: fullTrack.artistName,
|
_log.d('Track data keys: ${data.keys.toList()}');
|
||||||
albumName: fullTrack.albumName,
|
_log.d('ISRC from API: ${data['isrc']}');
|
||||||
albumArtist: fullTrack.albumArtist,
|
trackToDownload = Track(
|
||||||
coverUrl: fullTrack.coverUrl,
|
id: (data['spotify_id'] as String?) ?? trackToDownload.id,
|
||||||
duration: fullTrack.duration,
|
name: (data['name'] as String?) ?? trackToDownload.name,
|
||||||
isrc: fullTrack.isrc ?? trackToDownload.isrc,
|
artistName:
|
||||||
trackNumber: fullTrack.trackNumber,
|
(data['artists'] as String?) ?? trackToDownload.artistName,
|
||||||
discNumber: fullTrack.discNumber,
|
albumName:
|
||||||
releaseDate: fullTrack.releaseDate,
|
(data['album_name'] as String?) ??
|
||||||
deezerId: fullTrack.deezerId,
|
trackToDownload.albumName,
|
||||||
availability: trackToDownload.availability,
|
albumArtist: data['album_artist'] as String?,
|
||||||
);
|
coverUrl: data['images'] as String?,
|
||||||
_log.d('Metadata enriched: Track ${trackToDownload.trackNumber}, Disc ${trackToDownload.discNumber}, Year ${trackToDownload.releaseDate}');
|
// duration_ms from Go is in milliseconds, Track.duration is in seconds
|
||||||
|
duration:
|
||||||
// Update item in state with enriched track
|
((data['duration_ms'] as int?) ??
|
||||||
// This is important so the UI (and history) reflects the enriched data
|
(trackToDownload.duration * 1000)) ~/
|
||||||
// We don't perform a full `updateItemStatus` here to avoid UI flicker, just local var
|
1000,
|
||||||
|
isrc: (data['isrc'] as String?) ?? trackToDownload.isrc,
|
||||||
|
trackNumber: data['track_number'] as int?,
|
||||||
|
discNumber: data['disc_number'] as int?,
|
||||||
|
releaseDate: data['release_date'] as String?,
|
||||||
|
deezerId: rawId,
|
||||||
|
availability: trackToDownload.availability,
|
||||||
|
);
|
||||||
|
_log.d(
|
||||||
|
'Metadata enriched: Track ${trackToDownload.trackNumber}, Disc ${trackToDownload.discNumber}, ISRC ${trackToDownload.isrc}',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
_log.w('Unexpected track data type: ${trackData.runtimeType}');
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
_log.w('Response does not contain track key');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e, stack) {
|
||||||
_log.w('Failed to enrich metadata: $e');
|
_log.w('Failed to enrich metadata: $e');
|
||||||
|
_log.w('Stack trace: $stack');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final outputDir = await _buildOutputDir(trackToDownload, settings.folderOrganization);
|
// Log cover URL for debugging CSV import issues
|
||||||
|
_log.d('Track coverUrl after enrichment: ${trackToDownload.coverUrl}');
|
||||||
|
|
||||||
|
final outputDir = await _buildOutputDir(
|
||||||
|
trackToDownload,
|
||||||
|
settings.folderOrganization,
|
||||||
|
);
|
||||||
|
|
||||||
// Use quality override if set, otherwise use default from settings
|
// Use quality override if set, otherwise use default from settings
|
||||||
final quality = item.qualityOverride ?? state.audioQuality;
|
final quality = item.qualityOverride ?? state.audioQuality;
|
||||||
@@ -1140,7 +1284,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
|
|
||||||
if (state.autoFallback) {
|
if (state.autoFallback) {
|
||||||
_log.d('Using auto-fallback mode');
|
_log.d('Using auto-fallback mode');
|
||||||
_log.d('Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}');
|
_log.d(
|
||||||
|
'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}',
|
||||||
|
);
|
||||||
_log.d('Output dir: $outputDir');
|
_log.d('Output dir: $outputDir');
|
||||||
result = await PlatformBridge.downloadWithFallback(
|
result = await PlatformBridge.downloadWithFallback(
|
||||||
isrc: trackToDownload.isrc ?? '',
|
isrc: trackToDownload.isrc ?? '',
|
||||||
@@ -1158,7 +1304,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
releaseDate: trackToDownload.releaseDate,
|
releaseDate: trackToDownload.releaseDate,
|
||||||
preferredService: item.service,
|
preferredService: item.service,
|
||||||
itemId: item.id, // Pass item ID for progress tracking
|
itemId: item.id, // Pass item ID for progress tracking
|
||||||
durationMs: trackToDownload.duration, // Duration in ms for verification
|
durationMs:
|
||||||
|
trackToDownload.duration, // Duration in ms for verification
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
result = await PlatformBridge.downloadTrack(
|
result = await PlatformBridge.downloadTrack(
|
||||||
@@ -1177,14 +1324,18 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
discNumber: trackToDownload.discNumber ?? 1,
|
discNumber: trackToDownload.discNumber ?? 1,
|
||||||
releaseDate: trackToDownload.releaseDate,
|
releaseDate: trackToDownload.releaseDate,
|
||||||
itemId: item.id, // Pass item ID for progress tracking
|
itemId: item.id, // Pass item ID for progress tracking
|
||||||
durationMs: trackToDownload.duration, // Duration in ms for verification
|
durationMs:
|
||||||
|
trackToDownload.duration, // Duration in ms for verification
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
_log.d('Result: $result');
|
_log.d('Result: $result');
|
||||||
|
|
||||||
// Check if item was cancelled while downloading
|
// Check if item was cancelled while downloading
|
||||||
final currentItem = state.items.firstWhere((i) => i.id == item.id, orElse: () => item);
|
final currentItem = state.items.firstWhere(
|
||||||
|
(i) => i.id == item.id,
|
||||||
|
orElse: () => item,
|
||||||
|
);
|
||||||
if (currentItem.status == DownloadStatus.skipped) {
|
if (currentItem.status == DownloadStatus.skipped) {
|
||||||
_log.i('Download was cancelled, skipping result processing');
|
_log.i('Download was cancelled, skipping result processing');
|
||||||
// Delete the downloaded file if it exists
|
// Delete the downloaded file if it exists
|
||||||
@@ -1215,7 +1366,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
if (actualBitDepth != null && actualBitDepth > 0) {
|
if (actualBitDepth != null && actualBitDepth > 0) {
|
||||||
// Format: "24-bit/96kHz" or "16-bit/44.1kHz"
|
// Format: "24-bit/96kHz" or "16-bit/44.1kHz"
|
||||||
final sampleRateKHz = actualSampleRate != null && actualSampleRate > 0
|
final sampleRateKHz = actualSampleRate != null && actualSampleRate > 0
|
||||||
? (actualSampleRate / 1000).toStringAsFixed(actualSampleRate % 1000 == 0 ? 0 : 1)
|
? (actualSampleRate / 1000).toStringAsFixed(
|
||||||
|
actualSampleRate % 1000 == 0 ? 0 : 1,
|
||||||
|
)
|
||||||
: '?';
|
: '?';
|
||||||
actualQuality = '$actualBitDepth-bit/${sampleRateKHz}kHz';
|
actualQuality = '$actualBitDepth-bit/${sampleRateKHz}kHz';
|
||||||
_log.i('Actual quality: $actualQuality');
|
_log.i('Actual quality: $actualQuality');
|
||||||
@@ -1223,22 +1376,30 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
|
|
||||||
// M4A files from Tidal DASH streams - try to convert to FLAC
|
// M4A files from Tidal DASH streams - try to convert to FLAC
|
||||||
// M4A files from Tidal DASH streams - try to convert to FLAC
|
// M4A files from Tidal DASH streams - try to convert to FLAC
|
||||||
if (filePath != null && filePath!.endsWith('.m4a')) {
|
if (filePath != null && filePath.endsWith('.m4a')) {
|
||||||
_log.d('M4A file detected (Hi-Res DASH stream), attempting conversion to FLAC...');
|
_log.d(
|
||||||
|
'M4A file detected (Hi-Res DASH stream), attempting conversion to FLAC...',
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final file = File(filePath!);
|
final file = File(filePath);
|
||||||
if (!await file.exists()) {
|
if (!await file.exists()) {
|
||||||
_log.e('File does not exist at path: $filePath');
|
_log.e('File does not exist at path: $filePath');
|
||||||
} else {
|
} else {
|
||||||
final length = await file.length();
|
final length = await file.length();
|
||||||
_log.i('File size before conversion: ${length / 1024} KB');
|
_log.i('File size before conversion: ${length / 1024} KB');
|
||||||
|
|
||||||
if (length < 1024) {
|
if (length < 1024) {
|
||||||
_log.w('File is too small (<1KB), skipping conversion. Download might be corrupt.');
|
_log.w(
|
||||||
|
'File is too small (<1KB), skipping conversion. Download might be corrupt.',
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
updateItemStatus(item.id, DownloadStatus.downloading, progress: 0.95);
|
updateItemStatus(
|
||||||
final flacPath = await FFmpegService.convertM4aToFlac(filePath!);
|
item.id,
|
||||||
|
DownloadStatus.downloading,
|
||||||
|
progress: 0.95,
|
||||||
|
);
|
||||||
|
final flacPath = await FFmpegService.convertM4aToFlac(filePath);
|
||||||
|
|
||||||
if (flacPath != null) {
|
if (flacPath != null) {
|
||||||
filePath = flacPath;
|
filePath = flacPath;
|
||||||
@@ -1250,29 +1411,49 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
// Update track with actual metadata from backend result (if available)
|
// Update track with actual metadata from backend result (if available)
|
||||||
// This creates the most accurate metadata possible (from the service itself)
|
// This creates the most accurate metadata possible (from the service itself)
|
||||||
Track finalTrack = trackToDownload;
|
Track finalTrack = trackToDownload;
|
||||||
if (result.containsKey('track_number') || result.containsKey('release_date')) {
|
if (result.containsKey('track_number') ||
|
||||||
_log.d('Using metadata from backend response for embedding');
|
result.containsKey('release_date')) {
|
||||||
final backendTrackNum = result['track_number'] as int?;
|
_log.d(
|
||||||
final backendDiscNum = result['disc_number'] as int?;
|
'Using metadata from backend response for embedding',
|
||||||
final backendYear = result['release_date'] as String?;
|
);
|
||||||
final backendAlbum = result['album'] as String?;
|
final backendTrackNum = result['track_number'] as int?;
|
||||||
|
final backendDiscNum = result['disc_number'] as int?;
|
||||||
|
final backendYear = result['release_date'] as String?;
|
||||||
|
final backendAlbum = result['album'] as String?;
|
||||||
|
|
||||||
// Create updated track object
|
_log.d(
|
||||||
finalTrack = Track(
|
'Backend metadata - Track: $backendTrackNum, Disc: $backendDiscNum, Year: $backendYear',
|
||||||
id: trackToDownload.id,
|
);
|
||||||
name: trackToDownload.name,
|
|
||||||
artistName: trackToDownload.artistName,
|
// Create updated track object with safety check for 0/null
|
||||||
albumName: backendAlbum ?? trackToDownload.albumName,
|
final newTrackNumber =
|
||||||
albumArtist: trackToDownload.albumArtist,
|
(backendTrackNum != null && backendTrackNum > 0)
|
||||||
coverUrl: trackToDownload.coverUrl,
|
? backendTrackNum
|
||||||
duration: trackToDownload.duration,
|
: trackToDownload.trackNumber;
|
||||||
isrc: trackToDownload.isrc,
|
final newDiscNumber =
|
||||||
trackNumber: (backendTrackNum != null && backendTrackNum > 0) ? backendTrackNum : trackToDownload.trackNumber,
|
(backendDiscNum != null && backendDiscNum > 0)
|
||||||
discNumber: (backendDiscNum != null && backendDiscNum > 0) ? backendDiscNum : trackToDownload.discNumber,
|
? backendDiscNum
|
||||||
releaseDate: backendYear ?? trackToDownload.releaseDate,
|
: trackToDownload.discNumber;
|
||||||
deezerId: trackToDownload.deezerId,
|
|
||||||
availability: trackToDownload.availability,
|
_log.d(
|
||||||
);
|
'Final metadata for embedding - Track: $newTrackNumber, Disc: $newDiscNumber',
|
||||||
|
);
|
||||||
|
|
||||||
|
finalTrack = Track(
|
||||||
|
id: trackToDownload.id,
|
||||||
|
name: trackToDownload.name,
|
||||||
|
artistName: trackToDownload.artistName,
|
||||||
|
albumName: backendAlbum ?? trackToDownload.albumName,
|
||||||
|
albumArtist: trackToDownload.albumArtist,
|
||||||
|
coverUrl: trackToDownload.coverUrl,
|
||||||
|
duration: trackToDownload.duration,
|
||||||
|
isrc: trackToDownload.isrc,
|
||||||
|
trackNumber: newTrackNumber,
|
||||||
|
discNumber: newDiscNumber,
|
||||||
|
releaseDate: backendYear ?? trackToDownload.releaseDate,
|
||||||
|
deezerId: trackToDownload.deezerId,
|
||||||
|
availability: trackToDownload.availability,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use enriched/updated track for metadata embedding
|
// Use enriched/updated track for metadata embedding
|
||||||
@@ -1293,7 +1474,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check again if cancelled before updating status and adding to history
|
// Check again if cancelled before updating status and adding to history
|
||||||
final itemAfterDownload = state.items.firstWhere((i) => i.id == item.id, orElse: () => item);
|
final itemAfterDownload = state.items.firstWhere(
|
||||||
|
(i) => i.id == item.id,
|
||||||
|
orElse: () => item,
|
||||||
|
);
|
||||||
if (itemAfterDownload.status == DownloadStatus.skipped) {
|
if (itemAfterDownload.status == DownloadStatus.skipped) {
|
||||||
_log.i('Download was cancelled during finalization, cleaning up');
|
_log.i('Download was cancelled during finalization, cleaning up');
|
||||||
// Delete the downloaded file
|
// Delete the downloaded file
|
||||||
@@ -1330,35 +1514,58 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (filePath != null) {
|
if (filePath != null) {
|
||||||
// Extract updated metadata from backend result if available
|
// Extract metadata from backend result (most accurate source)
|
||||||
final backendTitle = result['title'] as String?;
|
final backendTitle = result['title'] as String?;
|
||||||
final backendArtist = result['artist'] as String?;
|
final backendArtist = result['artist'] as String?;
|
||||||
final backendAlbum = result['album'] as String?;
|
final backendAlbum = result['album'] as String?;
|
||||||
final backendYear = result['release_date'] as String?;
|
final backendYear = result['release_date'] as String?;
|
||||||
final backendTrackNum = result['track_number'] as int?;
|
final backendTrackNum = result['track_number'] as int?;
|
||||||
final backendDiscNum = result['disc_number'] as int?;
|
final backendDiscNum = result['disc_number'] as int?;
|
||||||
|
final backendBitDepth = result['actual_bit_depth'] as int?;
|
||||||
|
final backendSampleRate = result['actual_sample_rate'] as int?;
|
||||||
|
final backendISRC = result['isrc'] as String?;
|
||||||
|
|
||||||
ref.read(downloadHistoryProvider.notifier).addToHistory(
|
// Log cover URL for debugging
|
||||||
DownloadHistoryItem(
|
_log.d('Saving to history - coverUrl: ${trackToDownload.coverUrl}');
|
||||||
id: item.id,
|
|
||||||
trackName: (backendTitle != null && backendTitle.isNotEmpty) ? backendTitle : item.track.name,
|
ref
|
||||||
artistName: (backendArtist != null && backendArtist.isNotEmpty) ? backendArtist : item.track.artistName,
|
.read(downloadHistoryProvider.notifier)
|
||||||
albumName: (backendAlbum != null && backendAlbum.isNotEmpty) ? backendAlbum : item.track.albumName,
|
.addToHistory(
|
||||||
albumArtist: item.track.albumArtist,
|
DownloadHistoryItem(
|
||||||
coverUrl: item.track.coverUrl,
|
id: item.id,
|
||||||
filePath: filePath,
|
trackName: (backendTitle != null && backendTitle.isNotEmpty)
|
||||||
service: result['service'] as String? ?? item.service,
|
? backendTitle
|
||||||
downloadedAt: DateTime.now(),
|
: trackToDownload.name,
|
||||||
// Additional metadata
|
artistName: (backendArtist != null && backendArtist.isNotEmpty)
|
||||||
isrc: item.track.isrc,
|
? backendArtist
|
||||||
spotifyId: item.track.id,
|
: trackToDownload.artistName,
|
||||||
trackNumber: (backendTrackNum != null && backendTrackNum > 0) ? backendTrackNum : item.track.trackNumber,
|
albumName: (backendAlbum != null && backendAlbum.isNotEmpty)
|
||||||
discNumber: (backendDiscNum != null && backendDiscNum > 0) ? backendDiscNum : item.track.discNumber,
|
? backendAlbum
|
||||||
duration: item.track.duration,
|
: trackToDownload.albumName,
|
||||||
releaseDate: (backendYear != null && backendYear.isNotEmpty) ? backendYear : item.track.releaseDate,
|
albumArtist: trackToDownload.albumArtist,
|
||||||
quality: actualQuality,
|
coverUrl: trackToDownload.coverUrl,
|
||||||
),
|
filePath: filePath,
|
||||||
);
|
service: result['service'] as String? ?? item.service,
|
||||||
|
downloadedAt: DateTime.now(),
|
||||||
|
isrc: (backendISRC != null && backendISRC.isNotEmpty)
|
||||||
|
? backendISRC
|
||||||
|
: trackToDownload.isrc,
|
||||||
|
spotifyId: trackToDownload.id,
|
||||||
|
trackNumber: (backendTrackNum != null && backendTrackNum > 0)
|
||||||
|
? backendTrackNum
|
||||||
|
: trackToDownload.trackNumber,
|
||||||
|
discNumber: (backendDiscNum != null && backendDiscNum > 0)
|
||||||
|
? backendDiscNum
|
||||||
|
: trackToDownload.discNumber,
|
||||||
|
duration: trackToDownload.duration,
|
||||||
|
releaseDate: (backendYear != null && backendYear.isNotEmpty)
|
||||||
|
? backendYear
|
||||||
|
: trackToDownload.releaseDate,
|
||||||
|
quality: actualQuality,
|
||||||
|
bitDepth: backendBitDepth,
|
||||||
|
sampleRate: backendSampleRate,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// Auto-remove completed item from queue (it's now in history)
|
// Auto-remove completed item from queue (it's now in history)
|
||||||
removeItem(item.id);
|
removeItem(item.id);
|
||||||
@@ -1396,7 +1603,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
// Increment download counter and cleanup connections periodically
|
// Increment download counter and cleanup connections periodically
|
||||||
_downloadCount++;
|
_downloadCount++;
|
||||||
if (_downloadCount % _cleanupInterval == 0) {
|
if (_downloadCount % _cleanupInterval == 0) {
|
||||||
_log.d('Cleaning up idle connections (after $_downloadCount downloads)...');
|
_log.d(
|
||||||
|
'Cleaning up idle connections (after $_downloadCount downloads)...',
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
await PlatformBridge.cleanupConnections();
|
await PlatformBridge.cleanupConnections();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -1412,8 +1621,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
// Check for specific Deezer fallback error
|
// Check for specific Deezer fallback error
|
||||||
if (errorMsg.contains('could not find Deezer equivalent') ||
|
if (errorMsg.contains('could not find Deezer equivalent') ||
|
||||||
errorMsg.contains('track not found on Deezer')) {
|
errorMsg.contains('track not found on Deezer')) {
|
||||||
errorMsg = 'Track not found on Deezer (Metadata Unavailable)';
|
errorMsg = 'Track not found on Deezer (Metadata Unavailable)';
|
||||||
errorType = DownloadErrorType.notFound;
|
errorType = DownloadErrorType.notFound;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateItemStatus(
|
updateItemStatus(
|
||||||
@@ -1427,6 +1636,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final downloadQueueProvider = NotifierProvider<DownloadQueueNotifier, DownloadQueueState>(
|
final downloadQueueProvider =
|
||||||
DownloadQueueNotifier.new,
|
NotifierProvider<DownloadQueueNotifier, DownloadQueueState>(
|
||||||
);
|
DownloadQueueNotifier.new,
|
||||||
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:spotiflac_android/models/settings.dart';
|
import 'package:spotiflac_android/models/settings.dart';
|
||||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
|
|
||||||
const _settingsKey = 'app_settings';
|
const _settingsKey = 'app_settings';
|
||||||
const _migrationVersionKey = 'settings_migration_version';
|
const _migrationVersionKey = 'settings_migration_version';
|
||||||
@@ -26,6 +27,9 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
|
|
||||||
// Apply Spotify credentials to Go backend on load
|
// Apply Spotify credentials to Go backend on load
|
||||||
_applySpotifyCredentials();
|
_applySpotifyCredentials();
|
||||||
|
|
||||||
|
// Sync logging state
|
||||||
|
LogBuffer.loggingEnabled = state.enableLogging;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,6 +191,13 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
state = state.copyWith(metadataSource: source);
|
state = state.copyWith(metadataSource: source);
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setEnableLogging(bool enabled) {
|
||||||
|
state = state.copyWith(enableLogging: enabled);
|
||||||
|
_saveSettings();
|
||||||
|
// Sync logging state to LogBuffer
|
||||||
|
LogBuffer.loggingEnabled = enabled;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
|
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:spotiflac_android/models/track.dart';
|
import 'package:spotiflac_android/models/track.dart';
|
||||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
|
|
||||||
|
final _log = AppLogger('TrackProvider');
|
||||||
|
|
||||||
class TrackState {
|
class TrackState {
|
||||||
final List<Track> tracks;
|
final List<Track> tracks;
|
||||||
@@ -210,28 +213,60 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
// Use Deezer or Spotify based on settings
|
// Use Deezer or Spotify based on settings
|
||||||
final source = metadataSource ?? 'deezer';
|
final source = metadataSource ?? 'deezer';
|
||||||
|
|
||||||
// Debug log to show which source is being used
|
_log.i('Search started: source=$source, query="$query"');
|
||||||
// ignore: avoid_print
|
|
||||||
print('[Search] Using metadata source: $source for query: "$query"');
|
|
||||||
|
|
||||||
Map<String, dynamic> results;
|
Map<String, dynamic> results;
|
||||||
if (source == 'deezer') {
|
if (source == 'deezer') {
|
||||||
|
_log.d('Calling Deezer search API...');
|
||||||
results = await PlatformBridge.searchDeezerAll(query, trackLimit: 20, artistLimit: 5);
|
results = await PlatformBridge.searchDeezerAll(query, trackLimit: 20, artistLimit: 5);
|
||||||
// ignore: avoid_print
|
_log.i('Deezer returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists');
|
||||||
print('[Search] Deezer returned ${(results['tracks'] as List?)?.length ?? 0} tracks');
|
|
||||||
} else {
|
} else {
|
||||||
|
_log.d('Calling Spotify search API...');
|
||||||
results = await PlatformBridge.searchSpotifyAll(query, trackLimit: 20, artistLimit: 5);
|
results = await PlatformBridge.searchSpotifyAll(query, trackLimit: 20, artistLimit: 5);
|
||||||
// ignore: avoid_print
|
_log.i('Spotify returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists');
|
||||||
print('[Search] Spotify returned ${(results['tracks'] as List?)?.length ?? 0} tracks');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!_isRequestValid(requestId)) return; // Request cancelled
|
if (!_isRequestValid(requestId)) {
|
||||||
|
_log.w('Search request cancelled (requestId=$requestId)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final trackList = results['tracks'] as List<dynamic>? ?? [];
|
final trackList = results['tracks'] as List<dynamic>? ?? [];
|
||||||
final artistList = results['artists'] as List<dynamic>? ?? [];
|
final artistList = results['artists'] as List<dynamic>? ?? [];
|
||||||
|
|
||||||
final tracks = trackList.map((t) => _parseSearchTrack(t as Map<String, dynamic>)).toList();
|
_log.d('Raw results: ${trackList.length} tracks, ${artistList.length} artists');
|
||||||
final artists = artistList.map((a) => _parseSearchArtist(a as Map<String, dynamic>)).toList();
|
|
||||||
|
// Parse tracks with error handling per item
|
||||||
|
final tracks = <Track>[];
|
||||||
|
for (int i = 0; i < trackList.length; i++) {
|
||||||
|
final t = trackList[i];
|
||||||
|
try {
|
||||||
|
if (t is Map<String, dynamic>) {
|
||||||
|
tracks.add(_parseSearchTrack(t));
|
||||||
|
} else {
|
||||||
|
_log.w('Track[$i] is not a Map: ${t.runtimeType}');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_log.e('Failed to parse track[$i]: $e', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse artists with error handling per item
|
||||||
|
final artists = <SearchArtist>[];
|
||||||
|
for (int i = 0; i < artistList.length; i++) {
|
||||||
|
final a = artistList[i];
|
||||||
|
try {
|
||||||
|
if (a is Map<String, dynamic>) {
|
||||||
|
artists.add(_parseSearchArtist(a));
|
||||||
|
} else {
|
||||||
|
_log.w('Artist[$i] is not a Map: ${a.runtimeType}');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_log.e('Failed to parse artist[$i]: $e', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_log.i('Search complete: ${tracks.length} tracks, ${artists.length} artists parsed successfully');
|
||||||
|
|
||||||
state = TrackState(
|
state = TrackState(
|
||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
@@ -239,9 +274,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
hasSearchText: state.hasSearchText,
|
hasSearchText: state.hasSearchText,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e, stackTrace) {
|
||||||
if (!_isRequestValid(requestId)) return; // Request cancelled
|
if (!_isRequestValid(requestId)) return;
|
||||||
// Preserve hasSearchText on error so user stays on search screen
|
_log.e('Search failed: $e', e, stackTrace);
|
||||||
state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText);
|
state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -310,18 +345,27 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Track _parseSearchTrack(Map<String, dynamic> data) {
|
Track _parseSearchTrack(Map<String, dynamic> data) {
|
||||||
|
// Handle duration_ms which might be int or double
|
||||||
|
int durationMs = 0;
|
||||||
|
final durationValue = data['duration_ms'];
|
||||||
|
if (durationValue is int) {
|
||||||
|
durationMs = durationValue;
|
||||||
|
} else if (durationValue is double) {
|
||||||
|
durationMs = durationValue.toInt();
|
||||||
|
}
|
||||||
|
|
||||||
return Track(
|
return Track(
|
||||||
id: data['spotify_id'] as String? ?? '',
|
id: (data['spotify_id'] ?? data['id'] ?? '').toString(),
|
||||||
name: data['name'] as String? ?? '',
|
name: (data['name'] ?? '').toString(),
|
||||||
artistName: data['artists'] as String? ?? '',
|
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
|
||||||
albumName: data['album_name'] as String? ?? '',
|
albumName: (data['album_name'] ?? data['album'] ?? '').toString(),
|
||||||
albumArtist: data['album_artist'] as String?,
|
albumArtist: data['album_artist']?.toString(),
|
||||||
coverUrl: data['images'] as String?,
|
coverUrl: data['images']?.toString(),
|
||||||
isrc: data['isrc'] as String?,
|
isrc: data['isrc']?.toString(),
|
||||||
duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(),
|
duration: (durationMs / 1000).round(),
|
||||||
trackNumber: data['track_number'] as int?,
|
trackNumber: data['track_number'] as int?,
|
||||||
discNumber: data['disc_number'] as int?,
|
discNumber: data['disc_number'] as int?,
|
||||||
releaseDate: data['release_date'] as String?,
|
releaseDate: data['release_date']?.toString(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -159,6 +159,11 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
|
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
|
||||||
|
// Validate image URL - must be non-null, non-empty, and have a valid host
|
||||||
|
final hasValidImage = widget.coverUrl != null &&
|
||||||
|
widget.coverUrl!.isNotEmpty &&
|
||||||
|
Uri.tryParse(widget.coverUrl!)?.hasAuthority == true;
|
||||||
|
|
||||||
return SliverAppBar(
|
return SliverAppBar(
|
||||||
expandedHeight: 280,
|
expandedHeight: 280,
|
||||||
pinned: true,
|
pinned: true,
|
||||||
@@ -169,8 +174,15 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
background: Stack(
|
background: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
if (widget.coverUrl != null)
|
if (hasValidImage)
|
||||||
CachedNetworkImage(imageUrl: widget.coverUrl!, fit: BoxFit.cover, color: Colors.black.withValues(alpha: 0.5), colorBlendMode: BlendMode.darken, memCacheWidth: 600),
|
CachedNetworkImage(
|
||||||
|
imageUrl: widget.coverUrl!,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
color: Colors.black.withValues(alpha: 0.5),
|
||||||
|
colorBlendMode: BlendMode.darken,
|
||||||
|
memCacheWidth: 600,
|
||||||
|
errorWidget: (context, url, error) => Container(color: colorScheme.surfaceContainerHighest),
|
||||||
|
),
|
||||||
Container(
|
Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
@@ -192,8 +204,16 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 20, offset: const Offset(0, 10))],
|
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 20, offset: const Offset(0, 10))],
|
||||||
),
|
),
|
||||||
child: ClipOval(
|
child: ClipOval(
|
||||||
child: widget.coverUrl != null
|
child: hasValidImage
|
||||||
? CachedNetworkImage(imageUrl: widget.coverUrl!, fit: BoxFit.cover, memCacheWidth: 280)
|
? CachedNetworkImage(
|
||||||
|
imageUrl: widget.coverUrl!,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
memCacheWidth: 280,
|
||||||
|
errorWidget: (context, url, error) => Container(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
child: Icon(Icons.person, size: 48, color: colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
)
|
||||||
: Container(color: colorScheme.surfaceContainerHighest, child: Icon(Icons.person, size: 48, color: colorScheme.onSurfaceVariant)),
|
: Container(color: colorScheme.surfaceContainerHighest, child: Icon(Icons.person, size: 48, color: colorScheme.onSurfaceVariant)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
+156
-17
@@ -11,6 +11,7 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
|
|||||||
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||||
import 'package:spotiflac_android/screens/album_screen.dart';
|
import 'package:spotiflac_android/screens/album_screen.dart';
|
||||||
import 'package:spotiflac_android/screens/artist_screen.dart';
|
import 'package:spotiflac_android/screens/artist_screen.dart';
|
||||||
|
import 'package:spotiflac_android/services/csv_import_service.dart';
|
||||||
import 'package:spotiflac_android/screens/playlist_screen.dart';
|
import 'package:spotiflac_android/screens/playlist_screen.dart';
|
||||||
import 'package:spotiflac_android/models/download_item.dart';
|
import 'package:spotiflac_android/models/download_item.dart';
|
||||||
|
|
||||||
@@ -266,6 +267,104 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _importCsv(BuildContext context, WidgetRef ref) async {
|
||||||
|
// Show loading dialog with progress
|
||||||
|
int currentProgress = 0;
|
||||||
|
int totalTracks = 0;
|
||||||
|
|
||||||
|
// Use StatefulBuilder to update dialog content
|
||||||
|
final dialogContext = context;
|
||||||
|
bool dialogShown = false;
|
||||||
|
StateSetter? setDialogState;
|
||||||
|
|
||||||
|
void showProgressDialog() {
|
||||||
|
if (dialogShown) return;
|
||||||
|
dialogShown = true;
|
||||||
|
showDialog(
|
||||||
|
context: dialogContext,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (context) => StatefulBuilder(
|
||||||
|
builder: (context, setState) {
|
||||||
|
setDialogState = setState;
|
||||||
|
return AlertDialog(
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const CircularProgressIndicator(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
totalTracks > 0
|
||||||
|
? 'Fetching metadata... $currentProgress/$totalTracks'
|
||||||
|
: 'Reading CSV...',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final tracks = await CsvImportService.pickAndParseCsv(
|
||||||
|
onProgress: (current, total) {
|
||||||
|
currentProgress = current;
|
||||||
|
totalTracks = total;
|
||||||
|
if (!dialogShown && total > 0) {
|
||||||
|
showProgressDialog();
|
||||||
|
}
|
||||||
|
setDialogState?.call(() {});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Close progress dialog
|
||||||
|
if (dialogShown && mounted) {
|
||||||
|
Navigator.of(dialogContext).pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tracks.isNotEmpty) {
|
||||||
|
final settings = ref.read(settingsProvider);
|
||||||
|
|
||||||
|
// Optionally show confirmation dialog
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Import Playlist'),
|
||||||
|
content: Text('Found ${tracks.length} tracks in CSV. Add them to download queue?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
child: const Text('Import'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed == true) {
|
||||||
|
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Added ${tracks.length} tracks to queue'),
|
||||||
|
action: SnackBarAction(
|
||||||
|
label: 'View Queue',
|
||||||
|
onPressed: () {
|
||||||
|
// Navigate to queue tab (handled by main_shell index)
|
||||||
|
// We don't have direct access to set index here easily without provider
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Only show error if pick was not cancelled (handled inside service logging usually, but maybe show snackbar if file empty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
super.build(context);
|
super.build(context);
|
||||||
@@ -289,6 +388,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final hasResults = _isTyping || tracks.isNotEmpty || (searchArtists != null && searchArtists.isNotEmpty) || isLoading;
|
final hasResults = _isTyping || tracks.isNotEmpty || (searchArtists != null && searchArtists.isNotEmpty) || isLoading;
|
||||||
final screenHeight = MediaQuery.of(context).size.height;
|
final screenHeight = MediaQuery.of(context).size.height;
|
||||||
|
final topPadding = MediaQuery.of(context).padding.top;
|
||||||
final historyItems = ref.watch(downloadHistoryProvider.select((s) => s.items));
|
final historyItems = ref.watch(downloadHistoryProvider.select((s) => s.items));
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -297,24 +397,32 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
slivers: [
|
slivers: [
|
||||||
// App Bar - always present
|
// App Bar - always present
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
expandedHeight: 130,
|
expandedHeight: 120 + topPadding,
|
||||||
collapsedHeight: kToolbarHeight,
|
collapsedHeight: kToolbarHeight,
|
||||||
floating: false,
|
floating: false,
|
||||||
pinned: true,
|
pinned: true,
|
||||||
backgroundColor: colorScheme.surface,
|
backgroundColor: colorScheme.surface,
|
||||||
surfaceTintColor: Colors.transparent,
|
surfaceTintColor: Colors.transparent,
|
||||||
automaticallyImplyLeading: false,
|
automaticallyImplyLeading: false,
|
||||||
flexibleSpace: FlexibleSpaceBar(
|
flexibleSpace: LayoutBuilder(
|
||||||
expandedTitleScale: 1.3,
|
builder: (context, constraints) {
|
||||||
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
|
final maxHeight = 120 + topPadding;
|
||||||
title: Text(
|
final minHeight = kToolbarHeight + topPadding;
|
||||||
'Home',
|
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 28,
|
return FlexibleSpaceBar(
|
||||||
fontWeight: FontWeight.bold,
|
expandedTitleScale: 1.0,
|
||||||
color: colorScheme.onSurface,
|
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
|
||||||
),
|
title: Text(
|
||||||
),
|
'Home',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 20 + (14 * expandRatio), // 20 -> 34
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -329,12 +437,27 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
children: [
|
children: [
|
||||||
SizedBox(height: screenHeight * 0.06),
|
SizedBox(height: screenHeight * 0.06),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(24),
|
width: 96,
|
||||||
|
height: 96,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colorScheme.primaryContainer.withValues(alpha: 0.3),
|
color: colorScheme.primary,
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: Icon(Icons.music_note, size: 48, color: colorScheme.primary),
|
child: Image.asset(
|
||||||
|
'assets/images/logo-transparant.png',
|
||||||
|
color: colorScheme.onPrimary, // Tint with onPrimary color
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
errorBuilder: (_, _, _) => ClipRRect(
|
||||||
|
// Fallback to original logo if transparent one is missing
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
child: Image.asset(
|
||||||
|
'assets/images/logo.png',
|
||||||
|
width: 96,
|
||||||
|
height: 96,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
@@ -651,6 +774,11 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildArtistCard(SearchArtist artist, ColorScheme colorScheme) {
|
Widget _buildArtistCard(SearchArtist artist, ColorScheme colorScheme) {
|
||||||
|
// Validate image URL - must be non-null, non-empty, and have a valid host
|
||||||
|
final hasValidImage = artist.imageUrl != null &&
|
||||||
|
artist.imageUrl!.isNotEmpty &&
|
||||||
|
Uri.tryParse(artist.imageUrl!)?.hasAuthority == true;
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => _navigateToArtist(artist.id, artist.name, artist.imageUrl),
|
onTap: () => _navigateToArtist(artist.id, artist.name, artist.imageUrl),
|
||||||
child: Container(
|
child: Container(
|
||||||
@@ -666,12 +794,17 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
color: colorScheme.surfaceContainerHighest,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
),
|
),
|
||||||
child: ClipOval(
|
child: ClipOval(
|
||||||
child: artist.imageUrl != null
|
child: hasValidImage
|
||||||
? CachedNetworkImage(
|
? CachedNetworkImage(
|
||||||
imageUrl: artist.imageUrl!,
|
imageUrl: artist.imageUrl!,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
memCacheWidth: 200,
|
memCacheWidth: 200,
|
||||||
memCacheHeight: 200,
|
memCacheHeight: 200,
|
||||||
|
errorWidget: (context, url, error) => Icon(
|
||||||
|
Icons.person,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
size: 44,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
: Icon(Icons.person, color: colorScheme.onSurfaceVariant, size: 44),
|
: Icon(Icons.person, color: colorScheme.onSurfaceVariant, size: 44),
|
||||||
),
|
),
|
||||||
@@ -736,12 +869,18 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
onPressed: _clearAndRefresh,
|
onPressed: _clearAndRefresh,
|
||||||
tooltip: 'Clear',
|
tooltip: 'Clear',
|
||||||
)
|
)
|
||||||
else
|
else ...[
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.file_upload_outlined),
|
||||||
|
onPressed: () => _importCsv(context, ref),
|
||||||
|
tooltip: 'Import CSV',
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.paste),
|
icon: const Icon(Icons.paste),
|
||||||
onPressed: _pasteFromClipboard,
|
onPressed: _pasteFromClipboard,
|
||||||
tooltip: 'Paste',
|
tooltip: 'Paste',
|
||||||
),
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _handleSharedUrl(String url) {
|
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
|
// Navigate to Home tab
|
||||||
if (_currentIndex != 0) {
|
if (_currentIndex != 0) {
|
||||||
_onNavTap(0);
|
_onNavTap(0);
|
||||||
|
|||||||
+21
-12
@@ -99,29 +99,38 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
final historyItems = ref.watch(downloadHistoryProvider.select((s) => s.items));
|
final historyItems = ref.watch(downloadHistoryProvider.select((s) => s.items));
|
||||||
final historyViewMode = ref.watch(settingsProvider.select((s) => s.historyViewMode));
|
final historyViewMode = ref.watch(settingsProvider.select((s) => s.historyViewMode));
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final topPadding = MediaQuery.of(context).padding.top;
|
||||||
|
|
||||||
return CustomScrollView(
|
return CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
// Collapsing App Bar - Simplified for performance
|
// Collapsing App Bar - Simplified for performance
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
expandedHeight: 130,
|
expandedHeight: 120 + topPadding,
|
||||||
collapsedHeight: kToolbarHeight,
|
collapsedHeight: kToolbarHeight,
|
||||||
floating: false,
|
floating: false,
|
||||||
pinned: true,
|
pinned: true,
|
||||||
backgroundColor: colorScheme.surface,
|
backgroundColor: colorScheme.surface,
|
||||||
surfaceTintColor: Colors.transparent,
|
surfaceTintColor: Colors.transparent,
|
||||||
automaticallyImplyLeading: false,
|
automaticallyImplyLeading: false,
|
||||||
flexibleSpace: FlexibleSpaceBar(
|
flexibleSpace: LayoutBuilder(
|
||||||
expandedTitleScale: 1.3,
|
builder: (context, constraints) {
|
||||||
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
|
final maxHeight = 120 + topPadding;
|
||||||
title: Text(
|
final minHeight = kToolbarHeight + topPadding;
|
||||||
'History',
|
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 28,
|
return FlexibleSpaceBar(
|
||||||
fontWeight: FontWeight.bold,
|
expandedTitleScale: 1.0,
|
||||||
color: colorScheme.onSurface,
|
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
|
||||||
),
|
title: Text(
|
||||||
),
|
'History',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 20 + (14 * expandRatio), // 20 -> 34
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|||||||
@@ -109,14 +109,14 @@ class AboutPage extends StatelessWidget {
|
|||||||
githubUsername: 'sachinsenal0x64',
|
githubUsername: 'sachinsenal0x64',
|
||||||
showDivider: true,
|
showDivider: true,
|
||||||
),
|
),
|
||||||
SettingsItem(
|
_AboutSettingsItem(
|
||||||
icon: Icons.cloud_outlined,
|
icon: Icons.cloud_outlined,
|
||||||
title: 'DoubleDouble',
|
title: 'DoubleDouble',
|
||||||
subtitle: 'Amazing API for Amazon Music downloads. Thank you for making it free!',
|
subtitle: 'Amazing API for Amazon Music downloads. Thank you for making it free!',
|
||||||
onTap: () => _launchUrl('https://doubledouble.top'),
|
onTap: () => _launchUrl('https://doubledouble.top'),
|
||||||
showDivider: true,
|
showDivider: true,
|
||||||
),
|
),
|
||||||
SettingsItem(
|
_AboutSettingsItem(
|
||||||
icon: Icons.music_note_outlined,
|
icon: Icons.music_note_outlined,
|
||||||
title: 'DAB Music',
|
title: 'DAB Music',
|
||||||
subtitle: 'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!',
|
subtitle: 'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!',
|
||||||
@@ -249,30 +249,26 @@ class _AppHeaderCard extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
|
// App logo
|
||||||
// App logo
|
// App logo
|
||||||
Container(
|
Container(
|
||||||
width: 88,
|
width: 88,
|
||||||
height: 88,
|
height: 88,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colorScheme.primaryContainer,
|
color: colorScheme.primary,
|
||||||
borderRadius: BorderRadius.circular(24),
|
shape: BoxShape.circle,
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: colorScheme.primary.withValues(alpha: 0.2),
|
|
||||||
blurRadius: 16,
|
|
||||||
offset: const Offset(0, 4),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
child: ClipRRect(
|
child: Image.asset(
|
||||||
borderRadius: BorderRadius.circular(24),
|
'assets/images/logo-transparant.png',
|
||||||
child: Image.asset(
|
color: colorScheme.onPrimary, // Tint with onPrimary color
|
||||||
'assets/images/logo.png',
|
fit: BoxFit.contain,
|
||||||
fit: BoxFit.cover,
|
errorBuilder: (_, _, _) => ClipRRect(
|
||||||
errorBuilder: (_, _, _) => Icon(
|
borderRadius: BorderRadius.circular(24),
|
||||||
Icons.music_note,
|
child: Image.asset(
|
||||||
size: 48,
|
'assets/images/logo.png',
|
||||||
color: colorScheme.onPrimaryContainer,
|
width: 88,
|
||||||
|
height: 88,
|
||||||
|
fit: BoxFit.cover,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -417,3 +413,80 @@ class _ContributorItem extends StatelessWidget {
|
|||||||
await launchUrl(uri, mode: LaunchMode.inAppBrowserView);
|
await launchUrl(uri, mode: LaunchMode.inAppBrowserView);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Settings item with 40x40 icon area to align with contributor avatars
|
||||||
|
class _AboutSettingsItem extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String title;
|
||||||
|
final String? subtitle;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
final bool showDivider;
|
||||||
|
|
||||||
|
const _AboutSettingsItem({
|
||||||
|
required this.icon,
|
||||||
|
required this.title,
|
||||||
|
this.subtitle,
|
||||||
|
this.onTap,
|
||||||
|
this.showDivider = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
splashColor: colorScheme.primary.withValues(alpha: 0.12),
|
||||||
|
highlightColor: colorScheme.primary.withValues(alpha: 0.08),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// Icon with 40x40 size to match avatar
|
||||||
|
SizedBox(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
child: Icon(icon, color: colorScheme.onSurfaceVariant, size: 24),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
|
),
|
||||||
|
if (subtitle != null) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
subtitle!,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (onTap != null)
|
||||||
|
Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (showDivider)
|
||||||
|
Divider(
|
||||||
|
height: 1,
|
||||||
|
thickness: 1,
|
||||||
|
indent: 76, // 20 + 40 + 16 = 76 (same as contributor item)
|
||||||
|
endIndent: 20,
|
||||||
|
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,68 +27,108 @@ class AppearanceSettingsPage extends ConsumerWidget {
|
|||||||
pinned: true,
|
pinned: true,
|
||||||
backgroundColor: colorScheme.surface,
|
backgroundColor: colorScheme.surface,
|
||||||
surfaceTintColor: Colors.transparent,
|
surfaceTintColor: Colors.transparent,
|
||||||
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
|
leading: IconButton(
|
||||||
flexibleSpace: _AppBarTitle(title: 'Appearance', topPadding: topPadding),
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
),
|
||||||
|
flexibleSpace: _AppBarTitle(
|
||||||
|
title: 'Appearance',
|
||||||
|
topPadding: topPadding,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Preview Section
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
child: _ThemePreviewCard(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Color section
|
||||||
|
const SliverToBoxAdapter(
|
||||||
|
child: SettingsSectionHeader(title: 'Color'),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Theme section
|
|
||||||
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Theme')),
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
children: [
|
children: [
|
||||||
_ThemeModeSelector(
|
|
||||||
currentMode: themeSettings.themeMode,
|
|
||||||
onChanged: (mode) => ref.read(themeProvider.notifier).setThemeMode(mode),
|
|
||||||
),
|
|
||||||
SettingsSwitchItem(
|
SettingsSwitchItem(
|
||||||
icon: Icons.brightness_2,
|
icon: Icons.wallpaper,
|
||||||
title: 'AMOLED Dark',
|
title: 'Dynamic Color',
|
||||||
subtitle: 'Pure black background for OLED screens',
|
subtitle: 'Use colors from your wallpaper',
|
||||||
value: themeSettings.useAmoled,
|
value: themeSettings.useDynamicColor,
|
||||||
onChanged: (value) => ref.read(themeProvider.notifier).setUseAmoled(value),
|
onChanged: (value) => ref
|
||||||
|
.read(themeProvider.notifier)
|
||||||
|
.setUseDynamicColor(value),
|
||||||
showDivider: false,
|
showDivider: false,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (!themeSettings.useDynamicColor)
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||||
|
child: _ColorPalettePicker(
|
||||||
|
currentColor: themeSettings.seedColorValue,
|
||||||
|
onColorSelected: (color) =>
|
||||||
|
ref.read(themeProvider.notifier).setSeedColor(color),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// Color section
|
// Theme section
|
||||||
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Color')),
|
const SliverToBoxAdapter(
|
||||||
|
child: SettingsSectionHeader(title: 'Theme'),
|
||||||
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
children: [
|
children: [
|
||||||
SettingsSwitchItem(
|
_ThemeModeSelector(
|
||||||
icon: Icons.auto_awesome,
|
currentMode: themeSettings.themeMode,
|
||||||
title: 'Dynamic Color',
|
onChanged: (mode) =>
|
||||||
subtitle: 'Use colors from your wallpaper',
|
ref.read(themeProvider.notifier).setThemeMode(mode),
|
||||||
value: themeSettings.useDynamicColor,
|
|
||||||
onChanged: (value) => ref.read(themeProvider.notifier).setUseDynamicColor(value),
|
|
||||||
showDivider: !themeSettings.useDynamicColor,
|
|
||||||
),
|
),
|
||||||
if (!themeSettings.useDynamicColor)
|
if (Theme.of(context).brightness == Brightness.dark)
|
||||||
_ColorPicker(
|
SettingsSwitchItem(
|
||||||
currentColor: themeSettings.seedColorValue,
|
icon: Icons.brightness_2,
|
||||||
onColorSelected: (color) => ref.read(themeProvider.notifier).setSeedColor(color),
|
title: 'AMOLED Dark',
|
||||||
|
subtitle: 'Pure black background',
|
||||||
|
value: themeSettings.useAmoled,
|
||||||
|
onChanged: (value) =>
|
||||||
|
ref.read(themeProvider.notifier).setUseAmoled(value),
|
||||||
|
showDivider: false,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Layout section
|
// Layout section
|
||||||
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Layout')),
|
const SliverToBoxAdapter(
|
||||||
|
child: SettingsSectionHeader(title: 'Layout'),
|
||||||
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
children: [
|
children: [
|
||||||
_HistoryViewSelector(
|
_HistoryViewSelector(
|
||||||
currentMode: settings.historyViewMode,
|
currentMode: settings.historyViewMode,
|
||||||
onChanged: (mode) => ref.read(settingsProvider.notifier).setHistoryViewMode(mode),
|
onChanged: (mode) => ref
|
||||||
|
.read(settingsProvider.notifier)
|
||||||
|
.setHistoryViewMode(mode),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Fill remaining for scroll
|
// Fill remaining for scroll
|
||||||
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
|
const SliverFillRemaining(
|
||||||
|
hasScrollBody: false,
|
||||||
|
child: SizedBox(height: 32),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -96,6 +136,270 @@ class AppearanceSettingsPage extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A simplified preview of how the app looks with current settings
|
||||||
|
class _ThemePreviewCard extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
|
return RepaintBoundary(
|
||||||
|
child: Container(
|
||||||
|
height: 200,
|
||||||
|
width: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme
|
||||||
|
.surfaceContainerHighest, // Background similar to reference
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
// Decorative background blobs
|
||||||
|
Positioned(
|
||||||
|
top: -50,
|
||||||
|
right: -50,
|
||||||
|
child: Container(
|
||||||
|
width: 200,
|
||||||
|
height: 200,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: colorScheme.primaryContainer.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
bottom: -30,
|
||||||
|
left: -30,
|
||||||
|
child: Container(
|
||||||
|
width: 150,
|
||||||
|
height: 150,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: colorScheme.tertiaryContainer.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Foreground "fake UI"
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
width: 260,
|
||||||
|
height: 140,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surface,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.1),
|
||||||
|
blurRadius: 12, // Reduced from 20 for performance
|
||||||
|
offset: const Offset(0, 8),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// Fake Album Art
|
||||||
|
Container(
|
||||||
|
width: 108,
|
||||||
|
height: 108,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.primary,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.music_note,
|
||||||
|
color: colorScheme.onPrimary,
|
||||||
|
size: 48,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
|
||||||
|
// Fake Text Info
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 14,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(
|
||||||
|
width: 80,
|
||||||
|
height: 10,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.primary,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.skip_previous,
|
||||||
|
size: 24,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Icon(
|
||||||
|
Icons.play_circle_fill,
|
||||||
|
size: 32,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Icon(
|
||||||
|
Icons.skip_next,
|
||||||
|
size: 24,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Label badge
|
||||||
|
Positioned(
|
||||||
|
bottom: 12,
|
||||||
|
right: 12,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.6),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
isDark ? 'Dark Mode' : 'Light Mode',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ColorPalettePicker extends StatelessWidget {
|
||||||
|
final int currentColor;
|
||||||
|
final ValueChanged<Color> onColorSelected;
|
||||||
|
const _ColorPalettePicker({
|
||||||
|
required this.currentColor,
|
||||||
|
required this.onColorSelected,
|
||||||
|
});
|
||||||
|
|
||||||
|
static const _colors = [
|
||||||
|
Color(0xFF1DB954),
|
||||||
|
Color(0xFF6750A4),
|
||||||
|
Color(0xFF0061A4),
|
||||||
|
Color(0xFF006E1C),
|
||||||
|
Color(0xFFBA1A1A),
|
||||||
|
Color(0xFF984061),
|
||||||
|
Color(0xFF7D5260),
|
||||||
|
Color(0xFF006874),
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
child: Row(
|
||||||
|
children: _colors.map((color) {
|
||||||
|
final isSelected = color.toARGB32() == currentColor;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 12),
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () => onColorSelected(color),
|
||||||
|
child: _ColorPaletteItem(color: color, isSelected: isSelected),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ColorPaletteItem extends StatelessWidget {
|
||||||
|
final Color color;
|
||||||
|
final bool isSelected;
|
||||||
|
|
||||||
|
const _ColorPaletteItem({required this.color, required this.isSelected});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = ColorScheme.fromSeed(
|
||||||
|
seedColor: color,
|
||||||
|
brightness: Theme.of(context).brightness,
|
||||||
|
);
|
||||||
|
final size = 64.0;
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20)),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: Container(color: scheme.primaryContainer)),
|
||||||
|
Expanded(child: Container(color: scheme.tertiaryContainer)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Container(color: scheme.secondaryContainer),
|
||||||
|
),
|
||||||
|
Expanded(child: Container(color: scheme.surfaceContainer)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isSelected)
|
||||||
|
Positioned.fill(
|
||||||
|
child: Center(
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(4),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(Icons.check, size: 16, color: scheme.primary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Optimized app bar title with animation
|
/// Optimized app bar title with animation
|
||||||
class _AppBarTitle extends StatelessWidget {
|
class _AppBarTitle extends StatelessWidget {
|
||||||
final String title;
|
final String title;
|
||||||
@@ -110,7 +414,9 @@ class _AppBarTitle extends StatelessWidget {
|
|||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final maxHeight = 120 + topPadding;
|
final maxHeight = 120 + topPadding;
|
||||||
final minHeight = kToolbarHeight + topPadding;
|
final minHeight = kToolbarHeight + topPadding;
|
||||||
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
final expandRatio =
|
||||||
|
((constraints.maxHeight - minHeight) / (maxHeight - minHeight))
|
||||||
|
.clamp(0.0, 1.0);
|
||||||
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
|
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
|
||||||
return FlexibleSpaceBar(
|
return FlexibleSpaceBar(
|
||||||
expandedTitleScale: 1.0,
|
expandedTitleScale: 1.0,
|
||||||
@@ -132,19 +438,39 @@ class _AppBarTitle extends StatelessWidget {
|
|||||||
class _ThemeModeSelector extends StatelessWidget {
|
class _ThemeModeSelector extends StatelessWidget {
|
||||||
final ThemeMode currentMode;
|
final ThemeMode currentMode;
|
||||||
final ValueChanged<ThemeMode> onChanged;
|
final ValueChanged<ThemeMode> onChanged;
|
||||||
const _ThemeModeSelector({required this.currentMode, required this.onChanged});
|
const _ThemeModeSelector({
|
||||||
|
required this.currentMode,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
child: Row(children: [
|
child: Row(
|
||||||
_ThemeModeChip(icon: Icons.brightness_auto, label: 'System', isSelected: currentMode == ThemeMode.system, onTap: () => onChanged(ThemeMode.system)),
|
children: [
|
||||||
const SizedBox(width: 8),
|
_ThemeModeChip(
|
||||||
_ThemeModeChip(icon: Icons.light_mode, label: 'Light', isSelected: currentMode == ThemeMode.light, onTap: () => onChanged(ThemeMode.light)),
|
icon: Icons.brightness_auto,
|
||||||
const SizedBox(width: 8),
|
label: 'System',
|
||||||
_ThemeModeChip(icon: Icons.dark_mode, label: 'Dark', isSelected: currentMode == ThemeMode.dark, onTap: () => onChanged(ThemeMode.dark)),
|
isSelected: currentMode == ThemeMode.system,
|
||||||
]),
|
onTap: () => onChanged(ThemeMode.system),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_ThemeModeChip(
|
||||||
|
icon: Icons.light_mode,
|
||||||
|
label: 'Light',
|
||||||
|
isSelected: currentMode == ThemeMode.light,
|
||||||
|
onTap: () => onChanged(ThemeMode.light),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_ThemeModeChip(
|
||||||
|
icon: Icons.dark_mode,
|
||||||
|
label: 'Dark',
|
||||||
|
isSelected: currentMode == ThemeMode.dark,
|
||||||
|
onTap: () => onChanged(ThemeMode.dark),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -154,7 +480,12 @@ class _ThemeModeChip extends StatelessWidget {
|
|||||||
final String label;
|
final String label;
|
||||||
final bool isSelected;
|
final bool isSelected;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
const _ThemeModeChip({required this.icon, required this.label, required this.isSelected, required this.onTap});
|
const _ThemeModeChip({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.isSelected,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -165,8 +496,14 @@ class _ThemeModeChip extends StatelessWidget {
|
|||||||
// Card uses: dark = white 8% overlay, light = surfaceContainerHighest
|
// Card uses: dark = white 8% overlay, light = surfaceContainerHighest
|
||||||
// So chips use: dark = white 5% overlay (darker), light = black 5% overlay (darker than card)
|
// So chips use: dark = white 5% overlay (darker), light = black 5% overlay (darker than card)
|
||||||
final unselectedColor = isDark
|
final unselectedColor = isDark
|
||||||
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface)
|
? Color.alphaBlend(
|
||||||
: Color.alphaBlend(Colors.black.withValues(alpha: 0.05), colorScheme.surfaceContainerHighest);
|
Colors.white.withValues(alpha: 0.05),
|
||||||
|
colorScheme.surface,
|
||||||
|
)
|
||||||
|
: Color.alphaBlend(
|
||||||
|
Colors.black.withValues(alpha: 0.05),
|
||||||
|
colorScheme.surfaceContainerHighest,
|
||||||
|
);
|
||||||
|
|
||||||
return Expanded(
|
return Expanded(
|
||||||
child: Container(
|
child: Container(
|
||||||
@@ -174,7 +511,10 @@ class _ThemeModeChip extends StatelessWidget {
|
|||||||
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
|
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: !isDark && !isSelected
|
border: !isDark && !isSelected
|
||||||
? Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5), width: 1)
|
? Border.all(
|
||||||
|
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||||
|
width: 1,
|
||||||
|
)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
child: Material(
|
child: Material(
|
||||||
@@ -185,13 +525,29 @@ class _ThemeModeChip extends StatelessWidget {
|
|||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
child: Column(children: [
|
child: Column(
|
||||||
Icon(icon, color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant),
|
children: [
|
||||||
const SizedBox(height: 6),
|
Icon(
|
||||||
Text(label, style: TextStyle(fontSize: 12,
|
icon,
|
||||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
color: isSelected
|
||||||
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant)),
|
? colorScheme.onPrimaryContainer
|
||||||
]),
|
: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: isSelected
|
||||||
|
? FontWeight.w600
|
||||||
|
: FontWeight.normal,
|
||||||
|
color: isSelected
|
||||||
|
? colorScheme.onPrimaryContainer
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -200,49 +556,13 @@ class _ThemeModeChip extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ColorPicker extends StatelessWidget {
|
|
||||||
final int currentColor;
|
|
||||||
final ValueChanged<Color> onColorSelected;
|
|
||||||
const _ColorPicker({required this.currentColor, required this.onColorSelected});
|
|
||||||
|
|
||||||
static const _colors = [
|
|
||||||
Color(0xFF1DB954), Color(0xFF6750A4), Color(0xFF0061A4), Color(0xFF006E1C),
|
|
||||||
Color(0xFFBA1A1A), Color(0xFF984061), Color(0xFF7D5260), Color(0xFF006874), Color(0xFFFF6F00),
|
|
||||||
];
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 16),
|
|
||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
||||||
Text('Accent Color', style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Wrap(spacing: 12, runSpacing: 12, children: _colors.map((color) {
|
|
||||||
final isSelected = color.toARGB32() == currentColor;
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: () => onColorSelected(color),
|
|
||||||
child: AnimatedContainer(
|
|
||||||
duration: const Duration(milliseconds: 200),
|
|
||||||
width: 44, height: 44,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: color, shape: BoxShape.circle,
|
|
||||||
border: isSelected ? Border.all(color: colorScheme.onSurface, width: 3) : null,
|
|
||||||
boxShadow: isSelected ? [BoxShadow(color: color.withValues(alpha: 0.4), blurRadius: 8, spreadRadius: 2)] : null,
|
|
||||||
),
|
|
||||||
child: isSelected ? const Icon(Icons.check, color: Colors.white, size: 20) : null,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}).toList()),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _HistoryViewSelector extends StatelessWidget {
|
class _HistoryViewSelector extends StatelessWidget {
|
||||||
final String currentMode;
|
final String currentMode;
|
||||||
final ValueChanged<String> onChanged;
|
final ValueChanged<String> onChanged;
|
||||||
const _HistoryViewSelector({required this.currentMode, required this.onChanged});
|
const _HistoryViewSelector({
|
||||||
|
required this.currentMode,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -254,13 +574,30 @@ class _HistoryViewSelector extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(left: 8, bottom: 8),
|
padding: const EdgeInsets.only(left: 8, bottom: 8),
|
||||||
child: Text('History View', style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
child: Text(
|
||||||
|
'History View',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
_ViewModeChip(
|
||||||
|
icon: Icons.view_list,
|
||||||
|
label: 'List',
|
||||||
|
isSelected: currentMode == 'list',
|
||||||
|
onTap: () => onChanged('list'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_ViewModeChip(
|
||||||
|
icon: Icons.grid_view,
|
||||||
|
label: 'Grid',
|
||||||
|
isSelected: currentMode == 'grid',
|
||||||
|
onTap: () => onChanged('grid'),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
Row(children: [
|
|
||||||
_ViewModeChip(icon: Icons.view_list, label: 'List', isSelected: currentMode == 'list', onTap: () => onChanged('list')),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
_ViewModeChip(icon: Icons.grid_view, label: 'Grid', isSelected: currentMode == 'grid', onTap: () => onChanged('grid')),
|
|
||||||
]),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -272,7 +609,12 @@ class _ViewModeChip extends StatelessWidget {
|
|||||||
final String label;
|
final String label;
|
||||||
final bool isSelected;
|
final bool isSelected;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
const _ViewModeChip({required this.icon, required this.label, required this.isSelected, required this.onTap});
|
const _ViewModeChip({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.isSelected,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -281,8 +623,14 @@ class _ViewModeChip extends StatelessWidget {
|
|||||||
|
|
||||||
// Unselected chips need contrast with card background
|
// Unselected chips need contrast with card background
|
||||||
final unselectedColor = isDark
|
final unselectedColor = isDark
|
||||||
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface)
|
? Color.alphaBlend(
|
||||||
: Color.alphaBlend(Colors.black.withValues(alpha: 0.05), colorScheme.surfaceContainerHighest);
|
Colors.white.withValues(alpha: 0.05),
|
||||||
|
colorScheme.surface,
|
||||||
|
)
|
||||||
|
: Color.alphaBlend(
|
||||||
|
Colors.black.withValues(alpha: 0.05),
|
||||||
|
colorScheme.surfaceContainerHighest,
|
||||||
|
);
|
||||||
|
|
||||||
return Expanded(
|
return Expanded(
|
||||||
child: Container(
|
child: Container(
|
||||||
@@ -290,7 +638,10 @@ class _ViewModeChip extends StatelessWidget {
|
|||||||
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
|
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: !isDark && !isSelected
|
border: !isDark && !isSelected
|
||||||
? Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5), width: 1)
|
? Border.all(
|
||||||
|
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||||
|
width: 1,
|
||||||
|
)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
child: Material(
|
child: Material(
|
||||||
@@ -301,13 +652,29 @@ class _ViewModeChip extends StatelessWidget {
|
|||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
child: Column(children: [
|
child: Column(
|
||||||
Icon(icon, color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant),
|
children: [
|
||||||
const SizedBox(height: 6),
|
Icon(
|
||||||
Text(label, style: TextStyle(fontSize: 12,
|
icon,
|
||||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
color: isSelected
|
||||||
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant)),
|
? colorScheme.onPrimaryContainer
|
||||||
]),
|
: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: isSelected
|
||||||
|
? FontWeight.w600
|
||||||
|
: FontWeight.normal,
|
||||||
|
color: isSelected
|
||||||
|
? colorScheme.onPrimaryContainer
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -28,16 +28,25 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
pinned: true,
|
pinned: true,
|
||||||
backgroundColor: colorScheme.surface,
|
backgroundColor: colorScheme.surface,
|
||||||
surfaceTintColor: Colors.transparent,
|
surfaceTintColor: Colors.transparent,
|
||||||
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
),
|
||||||
flexibleSpace: LayoutBuilder(
|
flexibleSpace: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final maxHeight = 120 + topPadding;
|
final maxHeight = 120 + topPadding;
|
||||||
final minHeight = kToolbarHeight + topPadding;
|
final minHeight = kToolbarHeight + topPadding;
|
||||||
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
final expandRatio =
|
||||||
|
((constraints.maxHeight - minHeight) /
|
||||||
|
(maxHeight - minHeight))
|
||||||
|
.clamp(0.0, 1.0);
|
||||||
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
|
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
|
||||||
return FlexibleSpaceBar(
|
return FlexibleSpaceBar(
|
||||||
expandedTitleScale: 1.0,
|
expandedTitleScale: 1.0,
|
||||||
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
titlePadding: EdgeInsets.only(
|
||||||
|
left: leftPadding,
|
||||||
|
bottom: 16,
|
||||||
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
'Download',
|
'Download',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@@ -51,89 +60,117 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Service section
|
// Service section
|
||||||
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Service')),
|
const SliverToBoxAdapter(
|
||||||
SliverToBoxAdapter(
|
child: SettingsSectionHeader(title: 'Service'),
|
||||||
child: SettingsGroup(
|
),
|
||||||
children: [
|
SliverToBoxAdapter(
|
||||||
_ServiceSelector(
|
child: SettingsGroup(
|
||||||
currentService: settings.defaultService,
|
children: [
|
||||||
onChanged: (service) => ref.read(settingsProvider.notifier).setDefaultService(service),
|
_ServiceSelector(
|
||||||
),
|
currentService: settings.defaultService,
|
||||||
],
|
onChanged: (service) => ref
|
||||||
|
.read(settingsProvider.notifier)
|
||||||
|
.setDefaultService(service),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
|
||||||
// Quality section
|
// Quality section
|
||||||
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Audio Quality')),
|
const SliverToBoxAdapter(
|
||||||
SliverToBoxAdapter(
|
child: SettingsSectionHeader(title: 'Audio Quality'),
|
||||||
child: SettingsGroup(
|
),
|
||||||
children: [
|
SliverToBoxAdapter(
|
||||||
SettingsSwitchItem(
|
child: SettingsGroup(
|
||||||
icon: Icons.tune,
|
children: [
|
||||||
title: 'Ask Before Download',
|
SettingsSwitchItem(
|
||||||
subtitle: 'Choose quality for each download',
|
icon: Icons.tune,
|
||||||
value: settings.askQualityBeforeDownload,
|
title: 'Ask Before Download',
|
||||||
onChanged: (value) => ref.read(settingsProvider.notifier).setAskQualityBeforeDownload(value),
|
subtitle: 'Choose quality for each download',
|
||||||
),
|
value: settings.askQualityBeforeDownload,
|
||||||
if (!settings.askQualityBeforeDownload) ...[
|
onChanged: (value) => ref
|
||||||
_QualityOption(
|
.read(settingsProvider.notifier)
|
||||||
title: 'FLAC Lossless',
|
.setAskQualityBeforeDownload(value),
|
||||||
subtitle: '16-bit / 44.1kHz',
|
|
||||||
isSelected: settings.audioQuality == 'LOSSLESS',
|
|
||||||
onTap: () => ref.read(settingsProvider.notifier).setAudioQuality('LOSSLESS'),
|
|
||||||
),
|
),
|
||||||
_QualityOption(
|
if (!settings.askQualityBeforeDownload) ...[
|
||||||
title: 'Hi-Res FLAC',
|
_QualityOption(
|
||||||
subtitle: '24-bit / up to 96kHz',
|
title: 'FLAC Lossless',
|
||||||
isSelected: settings.audioQuality == 'HI_RES',
|
subtitle: '16-bit / 44.1kHz',
|
||||||
onTap: () => ref.read(settingsProvider.notifier).setAudioQuality('HI_RES'),
|
isSelected: settings.audioQuality == 'LOSSLESS',
|
||||||
|
onTap: () => ref
|
||||||
|
.read(settingsProvider.notifier)
|
||||||
|
.setAudioQuality('LOSSLESS'),
|
||||||
|
),
|
||||||
|
_QualityOption(
|
||||||
|
title: 'Hi-Res FLAC',
|
||||||
|
subtitle: '24-bit / up to 96kHz',
|
||||||
|
isSelected: settings.audioQuality == 'HI_RES',
|
||||||
|
onTap: () => ref
|
||||||
|
.read(settingsProvider.notifier)
|
||||||
|
.setAudioQuality('HI_RES'),
|
||||||
|
),
|
||||||
|
_QualityOption(
|
||||||
|
title: 'Hi-Res FLAC Max',
|
||||||
|
subtitle: '24-bit / up to 192kHz',
|
||||||
|
isSelected: settings.audioQuality == 'HI_RES_LOSSLESS',
|
||||||
|
onTap: () => ref
|
||||||
|
.read(settingsProvider.notifier)
|
||||||
|
.setAudioQuality('HI_RES_LOSSLESS'),
|
||||||
|
showDivider: false,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// File settings section
|
||||||
|
const SliverToBoxAdapter(
|
||||||
|
child: SettingsSectionHeader(title: 'File Settings'),
|
||||||
|
),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: SettingsGroup(
|
||||||
|
children: [
|
||||||
|
SettingsItem(
|
||||||
|
icon: Icons.text_fields,
|
||||||
|
title: 'Filename Format',
|
||||||
|
subtitle: settings.filenameFormat,
|
||||||
|
onTap: () => _showFormatEditor(
|
||||||
|
context,
|
||||||
|
ref,
|
||||||
|
settings.filenameFormat,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
_QualityOption(
|
SettingsItem(
|
||||||
title: 'Hi-Res FLAC Max',
|
icon: Icons.folder_outlined,
|
||||||
subtitle: '24-bit / up to 192kHz',
|
title: 'Download Directory',
|
||||||
isSelected: settings.audioQuality == 'HI_RES_LOSSLESS',
|
subtitle: settings.downloadDirectory.isEmpty
|
||||||
onTap: () => ref.read(settingsProvider.notifier).setAudioQuality('HI_RES_LOSSLESS'),
|
? (Platform.isIOS
|
||||||
|
? 'App Documents Folder'
|
||||||
|
: 'Music/SpotiFLAC')
|
||||||
|
: settings.downloadDirectory,
|
||||||
|
onTap: () => _pickDirectory(context, ref),
|
||||||
|
),
|
||||||
|
SettingsItem(
|
||||||
|
icon: Icons.create_new_folder_outlined,
|
||||||
|
title: 'Folder Organization',
|
||||||
|
subtitle: _getFolderOrganizationLabel(
|
||||||
|
settings.folderOrganization,
|
||||||
|
),
|
||||||
|
onTap: () => _showFolderOrganizationPicker(
|
||||||
|
context,
|
||||||
|
ref,
|
||||||
|
settings.folderOrganization,
|
||||||
|
),
|
||||||
showDivider: false,
|
showDivider: false,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
|
||||||
// File settings section
|
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||||
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'File Settings')),
|
],
|
||||||
SliverToBoxAdapter(
|
),
|
||||||
child: SettingsGroup(
|
|
||||||
children: [
|
|
||||||
SettingsItem(
|
|
||||||
icon: Icons.text_fields,
|
|
||||||
title: 'Filename Format',
|
|
||||||
subtitle: settings.filenameFormat,
|
|
||||||
onTap: () => _showFormatEditor(context, ref, settings.filenameFormat),
|
|
||||||
),
|
|
||||||
SettingsItem(
|
|
||||||
icon: Icons.folder_outlined,
|
|
||||||
title: 'Download Directory',
|
|
||||||
subtitle: settings.downloadDirectory.isEmpty
|
|
||||||
? (Platform.isIOS ? 'App Documents Folder' : 'Music/SpotiFLAC')
|
|
||||||
: settings.downloadDirectory,
|
|
||||||
onTap: () => _pickDirectory(context, ref),
|
|
||||||
),
|
|
||||||
SettingsItem(
|
|
||||||
icon: Icons.create_new_folder_outlined,
|
|
||||||
title: 'Folder Organization',
|
|
||||||
subtitle: _getFolderOrganizationLabel(settings.folderOrganization),
|
|
||||||
onTap: () => _showFolderOrganizationPicker(context, ref, settings.folderOrganization),
|
|
||||||
showDivider: false,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -141,26 +178,176 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
void _showFormatEditor(BuildContext context, WidgetRef ref, String current) {
|
void _showFormatEditor(BuildContext context, WidgetRef ref, String current) {
|
||||||
final controller = TextEditingController(text: current);
|
final controller = TextEditingController(text: current);
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
final tags = [
|
||||||
|
'{artist}',
|
||||||
|
'{title}',
|
||||||
|
'{album}',
|
||||||
|
'{track}',
|
||||||
|
'{year}',
|
||||||
|
'{disc}',
|
||||||
|
];
|
||||||
|
|
||||||
|
void insertTag(String tag) {
|
||||||
|
final text = controller.text;
|
||||||
|
final selection = controller.selection;
|
||||||
|
final start = selection.start >= 0 ? selection.start : text.length;
|
||||||
|
final end = selection.end >= 0 ? selection.end : text.length;
|
||||||
|
|
||||||
|
String insertion = tag;
|
||||||
|
if (start > 0) {
|
||||||
|
final before = text.substring(0, start);
|
||||||
|
// Smart separator: if not starting a file and no hyphen separator exists, add " - "
|
||||||
|
if (!before.trim().endsWith('-')) {
|
||||||
|
insertion = ' - $tag';
|
||||||
|
} else if (before.trim().endsWith('-') && !before.endsWith(' ')) {
|
||||||
|
// If ends with '-' but no space, add space
|
||||||
|
insertion = ' $tag';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final newText = text.replaceRange(start, end, insertion);
|
||||||
|
controller.value = TextEditingValue(
|
||||||
|
text: newText,
|
||||||
|
selection: TextSelection.collapsed(offset: start + insertion.length),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context, isScrollControlled: true,
|
context: context,
|
||||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
isScrollControlled: true,
|
||||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
|
backgroundColor: colorScheme.surface,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||||
|
),
|
||||||
builder: (context) => Padding(
|
builder: (context) => Padding(
|
||||||
padding: EdgeInsets.fromLTRB(24, 24, 24, MediaQuery.of(context).viewInsets.bottom + 24),
|
padding: EdgeInsets.only(
|
||||||
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||||
Text('Filename Format', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
),
|
||||||
const SizedBox(height: 16),
|
child: SingleChildScrollView(
|
||||||
TextField(controller: controller, decoration: const InputDecoration(hintText: '{artist} - {title}'), autofocus: true),
|
child: SafeArea(
|
||||||
const SizedBox(height: 16),
|
child: Padding(
|
||||||
Text('Available: {title}, {artist}, {album}, {track}, {year}, {disc}',
|
padding: const EdgeInsets.all(24),
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant)),
|
child: Column(
|
||||||
const SizedBox(height: 24),
|
mainAxisSize: MainAxisSize.min,
|
||||||
Row(mainAxisAlignment: MainAxisAlignment.end, children: [
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
|
children: [
|
||||||
const SizedBox(width: 8),
|
Center(
|
||||||
FilledButton(onPressed: () { ref.read(settingsProvider.notifier).setFilenameFormat(controller.text); Navigator.pop(context); }, child: const Text('Save')),
|
child: Container(
|
||||||
]),
|
width: 32,
|
||||||
]),
|
height: 4,
|
||||||
|
margin: const EdgeInsets.only(bottom: 24),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.outlineVariant,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'Filename Format',
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Customize how your files are named.',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
TextField(
|
||||||
|
controller: controller,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: '{artist} - {title}',
|
||||||
|
filled: true,
|
||||||
|
fillColor: colorScheme.surfaceContainerHighest.withValues(
|
||||||
|
alpha: 0.3,
|
||||||
|
),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
autofocus: true,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
Text(
|
||||||
|
'Tap to insert tag:',
|
||||||
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: tags.map((tag) {
|
||||||
|
return ActionChip(
|
||||||
|
label: Text(tag),
|
||||||
|
onPressed: () => insertTag(tag),
|
||||||
|
backgroundColor: colorScheme.surfaceContainerHighest
|
||||||
|
.withValues(alpha: 0.5),
|
||||||
|
side: BorderSide.none,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
labelStyle: TextStyle(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: () {
|
||||||
|
ref
|
||||||
|
.read(settingsProvider.notifier)
|
||||||
|
.setFilenameFormat(controller.text);
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Text('Save Format'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -172,7 +359,8 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
} else {
|
} else {
|
||||||
// Android: Use file picker
|
// Android: Use file picker
|
||||||
final result = await FilePicker.platform.getDirectoryPath();
|
final result = await FilePicker.platform.getDirectoryPath();
|
||||||
if (result != null) ref.read(settingsProvider.notifier).setDownloadDirectory(result);
|
if (result != null)
|
||||||
|
ref.read(settingsProvider.notifier).setDownloadDirectory(result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,7 +369,9 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||||
|
),
|
||||||
builder: (ctx) => SafeArea(
|
builder: (ctx) => SafeArea(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -189,13 +379,20 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||||
child: Text('Download Location', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
child: Text(
|
||||||
|
'Download Location',
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||||
child: Text(
|
child: Text(
|
||||||
'On iOS, downloads are saved to the app\'s Documents folder which is accessible via the Files app.',
|
'On iOS, downloads are saved to the app\'s Documents folder which is accessible via the Files app.',
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
@@ -205,7 +402,9 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
trailing: Icon(Icons.check_circle, color: colorScheme.primary),
|
trailing: Icon(Icons.check_circle, color: colorScheme.primary),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
final dir = await getApplicationDocumentsDirectory();
|
final dir = await getApplicationDocumentsDirectory();
|
||||||
ref.read(settingsProvider.notifier).setDownloadDirectory(dir.path);
|
ref
|
||||||
|
.read(settingsProvider.notifier)
|
||||||
|
.setDownloadDirectory(dir.path);
|
||||||
if (ctx.mounted) Navigator.pop(ctx);
|
if (ctx.mounted) Navigator.pop(ctx);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -218,7 +417,9 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
// Note: iOS requires folder to have at least one file to be selectable
|
// Note: iOS requires folder to have at least one file to be selectable
|
||||||
final result = await FilePicker.platform.getDirectoryPath();
|
final result = await FilePicker.platform.getDirectoryPath();
|
||||||
if (result != null) {
|
if (result != null) {
|
||||||
ref.read(settingsProvider.notifier).setDownloadDirectory(result);
|
ref
|
||||||
|
.read(settingsProvider.notifier)
|
||||||
|
.setDownloadDirectory(result);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -232,12 +433,18 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.info_outline, size: 20, color: colorScheme.tertiary),
|
Icon(
|
||||||
|
Icons.info_outline,
|
||||||
|
size: 20,
|
||||||
|
color: colorScheme.tertiary,
|
||||||
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'iOS limitation: Empty folders cannot be selected. Create a file inside first or use App Documents.',
|
'iOS limitation: Empty folders cannot be selected. Create a file inside first or use App Documents.',
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onTertiaryContainer),
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onTertiaryContainer,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -264,12 +471,18 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showFolderOrganizationPicker(BuildContext context, WidgetRef ref, String current) {
|
void _showFolderOrganizationPicker(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
String current,
|
||||||
|
) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||||
|
),
|
||||||
builder: (context) => SafeArea(
|
builder: (context) => SafeArea(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -277,39 +490,69 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||||
child: Text('Folder Organization', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
child: Text(
|
||||||
|
'Folder Organization',
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||||
child: Text('Organize downloaded files into folders', style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
child: Text(
|
||||||
|
'Organize downloaded files into folders',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
_FolderOption(
|
_FolderOption(
|
||||||
title: 'None',
|
title: 'None',
|
||||||
subtitle: 'All files in download folder',
|
subtitle: 'All files in download folder',
|
||||||
example: 'SpotiFLAC/Track.flac',
|
example: 'SpotiFLAC/Track.flac',
|
||||||
isSelected: current == 'none',
|
isSelected: current == 'none',
|
||||||
onTap: () { ref.read(settingsProvider.notifier).setFolderOrganization('none'); Navigator.pop(context); },
|
onTap: () {
|
||||||
|
ref
|
||||||
|
.read(settingsProvider.notifier)
|
||||||
|
.setFolderOrganization('none');
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
_FolderOption(
|
_FolderOption(
|
||||||
title: 'By Artist',
|
title: 'By Artist',
|
||||||
subtitle: 'Separate folder for each artist',
|
subtitle: 'Separate folder for each artist',
|
||||||
example: 'SpotiFLAC/Artist Name/Track.flac',
|
example: 'SpotiFLAC/Artist Name/Track.flac',
|
||||||
isSelected: current == 'artist',
|
isSelected: current == 'artist',
|
||||||
onTap: () { ref.read(settingsProvider.notifier).setFolderOrganization('artist'); Navigator.pop(context); },
|
onTap: () {
|
||||||
|
ref
|
||||||
|
.read(settingsProvider.notifier)
|
||||||
|
.setFolderOrganization('artist');
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
_FolderOption(
|
_FolderOption(
|
||||||
title: 'By Album',
|
title: 'By Album',
|
||||||
subtitle: 'Separate folder for each album',
|
subtitle: 'Separate folder for each album',
|
||||||
example: 'SpotiFLAC/Album Name/Track.flac',
|
example: 'SpotiFLAC/Album Name/Track.flac',
|
||||||
isSelected: current == 'album',
|
isSelected: current == 'album',
|
||||||
onTap: () { ref.read(settingsProvider.notifier).setFolderOrganization('album'); Navigator.pop(context); },
|
onTap: () {
|
||||||
|
ref
|
||||||
|
.read(settingsProvider.notifier)
|
||||||
|
.setFolderOrganization('album');
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
_FolderOption(
|
_FolderOption(
|
||||||
title: 'By Artist & Album',
|
title: 'By Artist & Album',
|
||||||
subtitle: 'Nested folders for artist and album',
|
subtitle: 'Nested folders for artist and album',
|
||||||
example: 'SpotiFLAC/Artist/Album/Track.flac',
|
example: 'SpotiFLAC/Artist/Album/Track.flac',
|
||||||
isSelected: current == 'artist_album',
|
isSelected: current == 'artist_album',
|
||||||
onTap: () { ref.read(settingsProvider.notifier).setFolderOrganization('artist_album'); Navigator.pop(context); },
|
onTap: () {
|
||||||
|
ref
|
||||||
|
.read(settingsProvider.notifier)
|
||||||
|
.setFolderOrganization('artist_album');
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
],
|
],
|
||||||
@@ -322,19 +565,39 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
class _ServiceSelector extends StatelessWidget {
|
class _ServiceSelector extends StatelessWidget {
|
||||||
final String currentService;
|
final String currentService;
|
||||||
final ValueChanged<String> onChanged;
|
final ValueChanged<String> onChanged;
|
||||||
const _ServiceSelector({required this.currentService, required this.onChanged});
|
const _ServiceSelector({
|
||||||
|
required this.currentService,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
child: Row(children: [
|
child: Row(
|
||||||
_ServiceChip(icon: Icons.music_note, label: 'Tidal', isSelected: currentService == 'tidal', onTap: () => onChanged('tidal')),
|
children: [
|
||||||
const SizedBox(width: 8),
|
_ServiceChip(
|
||||||
_ServiceChip(icon: Icons.album, label: 'Qobuz', isSelected: currentService == 'qobuz', onTap: () => onChanged('qobuz')),
|
icon: Icons.music_note,
|
||||||
const SizedBox(width: 8),
|
label: 'Tidal',
|
||||||
_ServiceChip(icon: Icons.shopping_bag, label: 'Amazon', isSelected: currentService == 'amazon', onTap: () => onChanged('amazon')),
|
isSelected: currentService == 'tidal',
|
||||||
]),
|
onTap: () => onChanged('tidal'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_ServiceChip(
|
||||||
|
icon: Icons.album,
|
||||||
|
label: 'Qobuz',
|
||||||
|
isSelected: currentService == 'qobuz',
|
||||||
|
onTap: () => onChanged('qobuz'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_ServiceChip(
|
||||||
|
icon: Icons.shopping_bag,
|
||||||
|
label: 'Amazon',
|
||||||
|
isSelected: currentService == 'amazon',
|
||||||
|
onTap: () => onChanged('amazon'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -344,7 +607,12 @@ class _ServiceChip extends StatelessWidget {
|
|||||||
final String label;
|
final String label;
|
||||||
final bool isSelected;
|
final bool isSelected;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
const _ServiceChip({required this.icon, required this.label, required this.isSelected, required this.onTap});
|
const _ServiceChip({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.isSelected,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -352,7 +620,10 @@ class _ServiceChip extends StatelessWidget {
|
|||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
final unselectedColor = isDark
|
final unselectedColor = isDark
|
||||||
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface)
|
? Color.alphaBlend(
|
||||||
|
Colors.white.withValues(alpha: 0.05),
|
||||||
|
colorScheme.surface,
|
||||||
|
)
|
||||||
: colorScheme.surfaceContainerHigh;
|
: colorScheme.surfaceContainerHigh;
|
||||||
|
|
||||||
return Expanded(
|
return Expanded(
|
||||||
@@ -364,13 +635,29 @@ class _ServiceChip extends StatelessWidget {
|
|||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
child: Column(children: [
|
child: Column(
|
||||||
Icon(icon, color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant),
|
children: [
|
||||||
const SizedBox(height: 6),
|
Icon(
|
||||||
Text(label, style: TextStyle(fontSize: 12,
|
icon,
|
||||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
color: isSelected
|
||||||
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant)),
|
? colorScheme.onPrimaryContainer
|
||||||
]),
|
: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: isSelected
|
||||||
|
? FontWeight.w600
|
||||||
|
: FontWeight.normal,
|
||||||
|
color: isSelected
|
||||||
|
? colorScheme.onPrimaryContainer
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -384,7 +671,13 @@ class _QualityOption extends StatelessWidget {
|
|||||||
final bool isSelected;
|
final bool isSelected;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
final bool showDivider;
|
final bool showDivider;
|
||||||
const _QualityOption({required this.title, required this.subtitle, required this.isSelected, required this.onTap, this.showDivider = true});
|
const _QualityOption({
|
||||||
|
required this.title,
|
||||||
|
required this.subtitle,
|
||||||
|
required this.isSelected,
|
||||||
|
required this.onTap,
|
||||||
|
this.showDivider = true,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -404,7 +697,12 @@ class _QualityOption extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Text(title, style: Theme.of(context).textTheme.bodyLarge),
|
Text(title, style: Theme.of(context).textTheme.bodyLarge),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(subtitle, style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
Text(
|
||||||
|
subtitle,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -434,7 +732,13 @@ class _FolderOption extends StatelessWidget {
|
|||||||
final String example;
|
final String example;
|
||||||
final bool isSelected;
|
final bool isSelected;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
const _FolderOption({required this.title, required this.subtitle, required this.example, required this.isSelected, required this.onTap});
|
const _FolderOption({
|
||||||
|
required this.title,
|
||||||
|
required this.subtitle,
|
||||||
|
required this.example,
|
||||||
|
required this.isSelected,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -447,10 +751,19 @@ class _FolderOption extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Text(subtitle),
|
Text(subtitle),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(example, style: TextStyle(fontFamily: 'monospace', fontSize: 11, color: colorScheme.primary)),
|
Text(
|
||||||
|
example,
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 11,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
trailing: isSelected ? Icon(Icons.check_circle, color: colorScheme.primary) : Icon(Icons.circle_outlined, color: colorScheme.outline),
|
trailing: isSelected
|
||||||
|
? Icon(Icons.check_circle, color: colorScheme.primary)
|
||||||
|
: Icon(Icons.circle_outlined, color: colorScheme.outline),
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,801 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:share_plus/share_plus.dart' show ShareParams, SharePlus;
|
||||||
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||||
|
|
||||||
|
class LogScreen extends StatefulWidget {
|
||||||
|
const LogScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<LogScreen> createState() => _LogScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LogScreenState extends State<LogScreen> {
|
||||||
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
final TextEditingController _searchController = TextEditingController();
|
||||||
|
String _selectedLevel = 'ALL';
|
||||||
|
String _searchQuery = '';
|
||||||
|
bool _autoScroll = true;
|
||||||
|
|
||||||
|
final List<String> _levels = ['ALL', 'DEBUG', 'INFO', 'WARN', 'ERROR'];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
LogBuffer().addListener(_onLogUpdate);
|
||||||
|
// Start polling Go backend logs
|
||||||
|
LogBuffer().startGoLogPolling();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
LogBuffer().removeListener(_onLogUpdate);
|
||||||
|
// Stop polling when leaving screen
|
||||||
|
LogBuffer().stopGoLogPolling();
|
||||||
|
_scrollController.dispose();
|
||||||
|
_searchController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onLogUpdate() {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {});
|
||||||
|
if (_autoScroll && _scrollController.hasClients) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (_scrollController.hasClients) {
|
||||||
|
_scrollController.animateTo(
|
||||||
|
_scrollController.position.maxScrollExtent,
|
||||||
|
duration: const Duration(milliseconds: 100),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<LogEntry> get _filteredLogs {
|
||||||
|
return LogBuffer().filter(
|
||||||
|
level: _selectedLevel,
|
||||||
|
search: _searchQuery.isEmpty ? null : _searchQuery,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _copyLogs() {
|
||||||
|
final logs = LogBuffer().export();
|
||||||
|
Clipboard.setData(ClipboardData(text: logs));
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: const Text('Logs copied to clipboard'),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
duration: const Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _shareLogs() {
|
||||||
|
final logs = LogBuffer().export();
|
||||||
|
SharePlus.instance.share(ShareParams(text: logs, subject: 'SpotiFLAC Logs'));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearLogs() {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Clear Logs'),
|
||||||
|
content: const Text('Are you sure you want to clear all logs?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () {
|
||||||
|
LogBuffer().clear();
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
child: const Text('Clear'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _getLevelColor(String level, ColorScheme colorScheme) {
|
||||||
|
switch (level) {
|
||||||
|
case 'ERROR':
|
||||||
|
case 'FATAL':
|
||||||
|
return colorScheme.error;
|
||||||
|
case 'WARN':
|
||||||
|
return Colors.orange;
|
||||||
|
case 'INFO':
|
||||||
|
return colorScheme.primary;
|
||||||
|
case 'DEBUG':
|
||||||
|
default:
|
||||||
|
return colorScheme.onSurfaceVariant;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final topPadding = MediaQuery.of(context).padding.top;
|
||||||
|
final logs = _filteredLogs;
|
||||||
|
|
||||||
|
return PopScope(
|
||||||
|
canPop: true,
|
||||||
|
child: Scaffold(
|
||||||
|
body: CustomScrollView(
|
||||||
|
controller: _scrollController,
|
||||||
|
slivers: [
|
||||||
|
// Collapsing App Bar with back button - same as other settings pages
|
||||||
|
SliverAppBar(
|
||||||
|
expandedHeight: 120 + topPadding,
|
||||||
|
collapsedHeight: kToolbarHeight,
|
||||||
|
floating: false,
|
||||||
|
pinned: true,
|
||||||
|
backgroundColor: colorScheme.surface,
|
||||||
|
surfaceTintColor: Colors.transparent,
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(_autoScroll ? Icons.vertical_align_bottom : Icons.vertical_align_center),
|
||||||
|
tooltip: _autoScroll ? 'Auto-scroll ON' : 'Auto-scroll OFF',
|
||||||
|
onPressed: () => setState(() => _autoScroll = !_autoScroll),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.copy),
|
||||||
|
tooltip: 'Copy logs',
|
||||||
|
onPressed: _copyLogs,
|
||||||
|
),
|
||||||
|
PopupMenuButton<String>(
|
||||||
|
icon: const Icon(Icons.more_vert),
|
||||||
|
onSelected: (value) {
|
||||||
|
switch (value) {
|
||||||
|
case 'share':
|
||||||
|
_shareLogs();
|
||||||
|
break;
|
||||||
|
case 'clear':
|
||||||
|
_clearLogs();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'share',
|
||||||
|
child: ListTile(
|
||||||
|
leading: Icon(Icons.share),
|
||||||
|
title: Text('Share logs'),
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'clear',
|
||||||
|
child: ListTile(
|
||||||
|
leading: Icon(Icons.delete_outline),
|
||||||
|
title: Text('Clear logs'),
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
flexibleSpace: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final maxHeight = 120 + topPadding;
|
||||||
|
final minHeight = kToolbarHeight + topPadding;
|
||||||
|
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
||||||
|
final leftPadding = 56 - (32 * expandRatio);
|
||||||
|
return FlexibleSpaceBar(
|
||||||
|
expandedTitleScale: 1.0,
|
||||||
|
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
||||||
|
title: Text(
|
||||||
|
'Logs',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 20 + (8 * expandRatio),
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Filter section
|
||||||
|
const SliverToBoxAdapter(
|
||||||
|
child: SettingsSectionHeader(title: 'Filter'),
|
||||||
|
),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: SettingsGroup(
|
||||||
|
children: [
|
||||||
|
// Level filter
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.filter_list, color: colorScheme.onSurfaceVariant),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('Level', style: Theme.of(context).textTheme.bodyLarge),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'Filter logs by severity',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DropdownButton<String>(
|
||||||
|
value: _selectedLevel,
|
||||||
|
underline: const SizedBox(),
|
||||||
|
items: _levels.map((level) {
|
||||||
|
return DropdownMenuItem(
|
||||||
|
value: level,
|
||||||
|
child: Text(
|
||||||
|
level,
|
||||||
|
style: TextStyle(
|
||||||
|
color: level == 'ALL'
|
||||||
|
? colorScheme.onSurface
|
||||||
|
: _getLevelColor(level, colorScheme),
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value != null) {
|
||||||
|
setState(() => _selectedLevel = value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Divider(
|
||||||
|
height: 1,
|
||||||
|
indent: 56,
|
||||||
|
endIndent: 20,
|
||||||
|
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
// Search field
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.search, color: colorScheme.onSurfaceVariant),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _searchController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Search logs...',
|
||||||
|
isDense: true,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
filled: true,
|
||||||
|
fillColor: colorScheme.surfaceContainerHighest,
|
||||||
|
suffixIcon: _searchQuery.isNotEmpty
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.clear, size: 20),
|
||||||
|
onPressed: () {
|
||||||
|
_searchController.clear();
|
||||||
|
setState(() => _searchQuery = '');
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() => _searchQuery = value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Log entries section
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: SettingsSectionHeader(
|
||||||
|
title: 'Entries (${logs.length}${_selectedLevel != 'ALL' || _searchQuery.isNotEmpty ? ' filtered' : ''})',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Error summary card - shows detected issues
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: _LogSummaryCard(logs: LogBuffer().entries),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Log list
|
||||||
|
logs.isEmpty
|
||||||
|
? SliverToBoxAdapter(
|
||||||
|
child: SettingsGroup(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 48),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.article_outlined,
|
||||||
|
size: 48,
|
||||||
|
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'No logs yet',
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Logs will appear here as you use the app',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: SliverToBoxAdapter(
|
||||||
|
child: SettingsGroup(
|
||||||
|
children: [
|
||||||
|
...logs.asMap().entries.map((entry) {
|
||||||
|
final index = entry.key;
|
||||||
|
final log = entry.value;
|
||||||
|
return _LogEntryTile(
|
||||||
|
entry: log,
|
||||||
|
levelColor: _getLevelColor(log.level, colorScheme),
|
||||||
|
showDivider: index < logs.length - 1,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Bottom padding
|
||||||
|
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LogEntryTile extends StatelessWidget {
|
||||||
|
final LogEntry entry;
|
||||||
|
final Color levelColor;
|
||||||
|
final bool showDivider;
|
||||||
|
|
||||||
|
const _LogEntryTile({
|
||||||
|
required this.entry,
|
||||||
|
required this.levelColor,
|
||||||
|
this.showDivider = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final isError = entry.level == 'ERROR' || entry.level == 'FATAL';
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isError
|
||||||
|
? colorScheme.errorContainer.withValues(alpha: 0.2)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Header: time, level, tag
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
entry.formattedTime,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: levelColor.withValues(alpha: 0.15),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
entry.level,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: levelColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (entry.isFromGo) ...[
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.teal.withValues(alpha: 0.15),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'Go',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.teal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
entry.tag,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
// Message
|
||||||
|
Text(
|
||||||
|
entry.message,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Error if present
|
||||||
|
if (entry.error != null) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
entry.error!,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
color: colorScheme.error,
|
||||||
|
height: 1.3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (showDivider)
|
||||||
|
Divider(
|
||||||
|
height: 1,
|
||||||
|
indent: 20,
|
||||||
|
endIndent: 20,
|
||||||
|
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Summary card showing detected issues in logs
|
||||||
|
class _LogSummaryCard extends StatelessWidget {
|
||||||
|
final List<LogEntry> logs;
|
||||||
|
|
||||||
|
const _LogSummaryCard({required this.logs});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
// Analyze logs for issues
|
||||||
|
final analysis = _analyzeLogs();
|
||||||
|
|
||||||
|
// Don't show if no issues detected
|
||||||
|
if (!analysis.hasIssues) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
|
child: Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: analysis.hasISPBlocking
|
||||||
|
? colorScheme.errorContainer.withValues(alpha: 0.5)
|
||||||
|
: colorScheme.tertiaryContainer.withValues(alpha: 0.5),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Header
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
analysis.hasISPBlocking ? Icons.block : Icons.warning_amber_rounded,
|
||||||
|
size: 20,
|
||||||
|
color: analysis.hasISPBlocking ? colorScheme.error : colorScheme.tertiary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'Issue Summary',
|
||||||
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// ISP Blocking detected
|
||||||
|
if (analysis.hasISPBlocking) ...[
|
||||||
|
_IssueBadge(
|
||||||
|
icon: Icons.block,
|
||||||
|
label: 'ISP BLOCKING DETECTED',
|
||||||
|
description: 'Your ISP may be blocking access to download services',
|
||||||
|
suggestion: 'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8',
|
||||||
|
color: colorScheme.error,
|
||||||
|
domains: analysis.blockedDomains,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
if (analysis.hasRateLimit) ...[
|
||||||
|
_IssueBadge(
|
||||||
|
icon: Icons.speed,
|
||||||
|
label: 'RATE LIMITED',
|
||||||
|
description: 'Too many requests to the service',
|
||||||
|
suggestion: 'Wait a few minutes before trying again',
|
||||||
|
color: Colors.orange,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Network errors
|
||||||
|
if (analysis.hasNetworkError && !analysis.hasISPBlocking) ...[
|
||||||
|
_IssueBadge(
|
||||||
|
icon: Icons.wifi_off,
|
||||||
|
label: 'NETWORK ERROR',
|
||||||
|
description: 'Connection issues detected',
|
||||||
|
suggestion: 'Check your internet connection',
|
||||||
|
color: colorScheme.tertiary,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Track not found
|
||||||
|
if (analysis.hasNotFound) ...[
|
||||||
|
_IssueBadge(
|
||||||
|
icon: Icons.search_off,
|
||||||
|
label: 'TRACK NOT FOUND',
|
||||||
|
description: 'Some tracks could not be found on download services',
|
||||||
|
suggestion: 'The track may not be available in lossless quality',
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Error count
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'Total errors: ${analysis.errorCount}',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_LogAnalysis _analyzeLogs() {
|
||||||
|
int errorCount = 0;
|
||||||
|
bool hasISPBlocking = false;
|
||||||
|
bool hasRateLimit = false;
|
||||||
|
bool hasNetworkError = false;
|
||||||
|
bool hasNotFound = false;
|
||||||
|
final Set<String> blockedDomains = {};
|
||||||
|
|
||||||
|
for (final log in logs) {
|
||||||
|
if (log.level == 'ERROR' || log.level == 'FATAL') {
|
||||||
|
errorCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
final msgLower = log.message.toLowerCase();
|
||||||
|
final errorLower = (log.error ?? '').toLowerCase();
|
||||||
|
final combined = '$msgLower $errorLower';
|
||||||
|
|
||||||
|
// Check for ISP blocking (detected by Go backend)
|
||||||
|
if (combined.contains('isp blocking') ||
|
||||||
|
combined.contains('isp may be') ||
|
||||||
|
combined.contains('blocked by isp') ||
|
||||||
|
combined.contains('connection reset') ||
|
||||||
|
combined.contains('connection refused')) {
|
||||||
|
hasISPBlocking = true;
|
||||||
|
|
||||||
|
// Try to extract domain
|
||||||
|
final domainMatch = RegExp(r'domain:\s*([^\s,]+)', caseSensitive: false).firstMatch(combined);
|
||||||
|
if (domainMatch != null) {
|
||||||
|
blockedDomains.add(domainMatch.group(1)!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for rate limiting
|
||||||
|
if (combined.contains('rate limit') ||
|
||||||
|
combined.contains('429') ||
|
||||||
|
combined.contains('too many requests')) {
|
||||||
|
hasRateLimit = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for network errors
|
||||||
|
if (combined.contains('connection') ||
|
||||||
|
combined.contains('timeout') ||
|
||||||
|
combined.contains('network') ||
|
||||||
|
combined.contains('dial')) {
|
||||||
|
hasNetworkError = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for not found
|
||||||
|
if (combined.contains('not found') ||
|
||||||
|
combined.contains('no results') ||
|
||||||
|
combined.contains('could not find')) {
|
||||||
|
hasNotFound = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return _LogAnalysis(
|
||||||
|
errorCount: errorCount,
|
||||||
|
hasISPBlocking: hasISPBlocking,
|
||||||
|
hasRateLimit: hasRateLimit,
|
||||||
|
hasNetworkError: hasNetworkError,
|
||||||
|
hasNotFound: hasNotFound,
|
||||||
|
blockedDomains: blockedDomains.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LogAnalysis {
|
||||||
|
final int errorCount;
|
||||||
|
final bool hasISPBlocking;
|
||||||
|
final bool hasRateLimit;
|
||||||
|
final bool hasNetworkError;
|
||||||
|
final bool hasNotFound;
|
||||||
|
final List<String> blockedDomains;
|
||||||
|
|
||||||
|
_LogAnalysis({
|
||||||
|
required this.errorCount,
|
||||||
|
required this.hasISPBlocking,
|
||||||
|
required this.hasRateLimit,
|
||||||
|
required this.hasNetworkError,
|
||||||
|
required this.hasNotFound,
|
||||||
|
required this.blockedDomains,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool get hasIssues => errorCount > 0 || hasISPBlocking || hasRateLimit || hasNetworkError || hasNotFound;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _IssueBadge extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final String description;
|
||||||
|
final String suggestion;
|
||||||
|
final Color color;
|
||||||
|
final List<String>? domains;
|
||||||
|
|
||||||
|
const _IssueBadge({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.description,
|
||||||
|
required this.suggestion,
|
||||||
|
required this.color,
|
||||||
|
this.domains,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: color.withValues(alpha: 0.3)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 16, color: color),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
description,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (domains != null && domains!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Affected: ${domains!.join(", ")}',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 11,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.lightbulb_outline, size: 14, color: colorScheme.primary),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
suggestion,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.primary,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ import 'package:spotiflac_android/screens/settings/appearance_settings_page.dart
|
|||||||
import 'package:spotiflac_android/screens/settings/download_settings_page.dart';
|
import 'package:spotiflac_android/screens/settings/download_settings_page.dart';
|
||||||
import 'package:spotiflac_android/screens/settings/options_settings_page.dart';
|
import 'package:spotiflac_android/screens/settings/options_settings_page.dart';
|
||||||
import 'package:spotiflac_android/screens/settings/about_page.dart';
|
import 'package:spotiflac_android/screens/settings/about_page.dart';
|
||||||
|
import 'package:spotiflac_android/screens/settings/log_screen.dart';
|
||||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||||
|
|
||||||
class SettingsTab extends ConsumerWidget {
|
class SettingsTab extends ConsumerWidget {
|
||||||
@@ -13,29 +14,38 @@ class SettingsTab extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final topPadding = MediaQuery.of(context).padding.top;
|
||||||
|
|
||||||
return CustomScrollView(
|
return CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
// Collapsing App Bar
|
// Collapsing App Bar
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
expandedHeight: 130,
|
expandedHeight: 120 + topPadding,
|
||||||
collapsedHeight: kToolbarHeight,
|
collapsedHeight: kToolbarHeight,
|
||||||
floating: false,
|
floating: false,
|
||||||
pinned: true,
|
pinned: true,
|
||||||
backgroundColor: colorScheme.surface,
|
backgroundColor: colorScheme.surface,
|
||||||
surfaceTintColor: Colors.transparent,
|
surfaceTintColor: Colors.transparent,
|
||||||
automaticallyImplyLeading: false,
|
automaticallyImplyLeading: false,
|
||||||
flexibleSpace: FlexibleSpaceBar(
|
flexibleSpace: LayoutBuilder(
|
||||||
expandedTitleScale: 1.3,
|
builder: (context, constraints) {
|
||||||
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
|
final maxHeight = 120 + topPadding;
|
||||||
title: Text(
|
final minHeight = kToolbarHeight + topPadding;
|
||||||
'Settings',
|
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 28,
|
return FlexibleSpaceBar(
|
||||||
fontWeight: FontWeight.bold,
|
expandedTitleScale: 1.0,
|
||||||
color: colorScheme.onSurface,
|
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
|
||||||
),
|
title: Text(
|
||||||
),
|
'Settings',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 20 + (14 * expandRatio), // 20 -> 34
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -67,10 +77,16 @@ class SettingsTab extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Second group: About
|
// Second group: Logs & About
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
children: [
|
children: [
|
||||||
|
SettingsItem(
|
||||||
|
icon: Icons.article_outlined,
|
||||||
|
title: 'Logs',
|
||||||
|
subtitle: 'View app logs for debugging',
|
||||||
|
onTap: () => _navigateTo(context, const LogScreen()),
|
||||||
|
),
|
||||||
SettingsItem(
|
SettingsItem(
|
||||||
icon: Icons.info_outline,
|
icon: Icons.info_outline,
|
||||||
title: 'About',
|
title: 'About',
|
||||||
|
|||||||
@@ -37,11 +37,13 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
final file = File(widget.item.filePath);
|
final file = File(widget.item.filePath);
|
||||||
final exists = await file.exists();
|
final exists = await file.exists();
|
||||||
int? size;
|
int? size;
|
||||||
|
|
||||||
if (exists) {
|
if (exists) {
|
||||||
try {
|
try {
|
||||||
size = await file.length();
|
size = await file.length();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_fileExists = exists;
|
_fileExists = exists;
|
||||||
@@ -55,7 +57,18 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use data directly from history item (cached from download)
|
||||||
DownloadHistoryItem get item => widget.item;
|
DownloadHistoryItem get item => widget.item;
|
||||||
|
String get trackName => item.trackName;
|
||||||
|
String get artistName => item.artistName;
|
||||||
|
String get albumName => item.albumName;
|
||||||
|
String? get albumArtist => item.albumArtist;
|
||||||
|
int? get trackNumber => item.trackNumber;
|
||||||
|
int? get discNumber => item.discNumber;
|
||||||
|
String? get releaseDate => item.releaseDate;
|
||||||
|
String? get isrc => item.isrc;
|
||||||
|
int? get bitDepth => item.bitDepth;
|
||||||
|
int? get sampleRate => item.sampleRate;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -233,9 +246,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Track name
|
// Track name (from file metadata)
|
||||||
Text(
|
Text(
|
||||||
item.trackName,
|
trackName,
|
||||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: colorScheme.onSurface,
|
color: colorScheme.onSurface,
|
||||||
@@ -243,16 +256,16 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
|
|
||||||
// Artist name
|
// Artist name (from file metadata)
|
||||||
Text(
|
Text(
|
||||||
item.artistName,
|
artistName,
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
color: colorScheme.primary,
|
color: colorScheme.primary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
// Album name
|
// Album name (from file metadata)
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
@@ -263,7 +276,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
item.albumName,
|
albumName,
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
@@ -340,19 +353,24 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
// Metadata grid
|
// Metadata grid
|
||||||
_buildMetadataGrid(context, colorScheme),
|
_buildMetadataGrid(context, colorScheme),
|
||||||
|
|
||||||
// Spotify link button
|
// Streaming service link button
|
||||||
if (item.spotifyId != null && item.spotifyId!.isNotEmpty) ...[
|
if (item.spotifyId != null && item.spotifyId!.isNotEmpty) ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
OutlinedButton.icon(
|
Builder(
|
||||||
onPressed: () => _openSpotifyUrl(context),
|
builder: (context) {
|
||||||
icon: const Icon(Icons.open_in_new, size: 18),
|
final isDeezer = item.spotifyId!.contains('deezer');
|
||||||
label: const Text('Open in Spotify'),
|
return OutlinedButton.icon(
|
||||||
style: OutlinedButton.styleFrom(
|
onPressed: () => _openServiceUrl(context),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
icon: const Icon(Icons.open_in_new, size: 18),
|
||||||
shape: RoundedRectangleBorder(
|
label: Text(isDeezer ? 'Open in Deezer' : 'Open in Spotify'),
|
||||||
borderRadius: BorderRadius.circular(12),
|
style: OutlinedButton.styleFrom(
|
||||||
),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||||
),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -361,16 +379,24 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _openSpotifyUrl(BuildContext context) async {
|
Future<void> _openServiceUrl(BuildContext context) async {
|
||||||
if (item.spotifyId == null) return;
|
if (item.spotifyId == null) return;
|
||||||
|
|
||||||
final webUrl = 'https://open.spotify.com/track/${item.spotifyId}';
|
final isDeezer = item.spotifyId!.contains('deezer');
|
||||||
final spotifyUri = Uri.parse('spotify:track:${item.spotifyId}');
|
final rawId = item.spotifyId!.replaceAll('deezer:', '');
|
||||||
|
|
||||||
|
final webUrl = isDeezer
|
||||||
|
? 'https://www.deezer.com/track/$rawId'
|
||||||
|
: 'https://open.spotify.com/track/$rawId';
|
||||||
|
|
||||||
|
final appUri = isDeezer
|
||||||
|
? Uri.parse('deezer://www.deezer.com/track/$rawId')
|
||||||
|
: Uri.parse('spotify:track:$rawId');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Try to open in Spotify app first using URI scheme
|
// Try to open in App first using URI scheme
|
||||||
final launched = await launchUrl(
|
final launched = await launchUrl(
|
||||||
spotifyUri,
|
appUri,
|
||||||
mode: LaunchMode.externalApplication,
|
mode: LaunchMode.externalApplication,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -393,7 +419,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
_copyToClipboard(context, webUrl);
|
_copyToClipboard(context, webUrl);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Spotify URL copied to clipboard')),
|
SnackBar(content: Text('${isDeezer ? 'Deezer' : 'Spotify'} URL copied to clipboard')),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -401,31 +427,43 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildMetadataGrid(BuildContext context, ColorScheme colorScheme) {
|
Widget _buildMetadataGrid(BuildContext context, ColorScheme colorScheme) {
|
||||||
|
// Build audio quality string from file metadata
|
||||||
|
String? audioQualityStr;
|
||||||
|
if (bitDepth != null && sampleRate != null) {
|
||||||
|
final sampleRateKHz = (sampleRate! / 1000).toStringAsFixed(1);
|
||||||
|
audioQualityStr = '$bitDepth-bit/${sampleRateKHz}kHz';
|
||||||
|
}
|
||||||
|
|
||||||
final items = <_MetadataItem>[
|
final items = <_MetadataItem>[
|
||||||
_MetadataItem('Track name', item.trackName),
|
_MetadataItem('Track name', trackName),
|
||||||
_MetadataItem('Artist', item.artistName),
|
_MetadataItem('Artist', artistName),
|
||||||
if (item.albumArtist != null && item.albumArtist != item.artistName)
|
if (albumArtist != null && albumArtist != artistName)
|
||||||
_MetadataItem('Album artist', item.albumArtist!),
|
_MetadataItem('Album artist', albumArtist!),
|
||||||
_MetadataItem('Album', item.albumName),
|
_MetadataItem('Album', albumName),
|
||||||
if (item.trackNumber != null)
|
if (trackNumber != null && trackNumber! > 0)
|
||||||
_MetadataItem('Track number', item.trackNumber.toString()),
|
_MetadataItem('Track number', trackNumber.toString()),
|
||||||
if (item.discNumber != null && item.discNumber! > 1)
|
if (discNumber != null && discNumber! > 0)
|
||||||
_MetadataItem('Disc number', item.discNumber.toString()),
|
_MetadataItem('Disc number', discNumber.toString()),
|
||||||
if (item.duration != null)
|
if (item.duration != null)
|
||||||
_MetadataItem('Duration', _formatDuration(item.duration!)),
|
_MetadataItem('Duration', _formatDuration(item.duration!)),
|
||||||
if (item.quality != null && item.quality!.contains('bit'))
|
if (audioQualityStr != null)
|
||||||
_MetadataItem('Audio quality', item.quality!),
|
_MetadataItem('Audio quality', audioQualityStr),
|
||||||
if (item.releaseDate != null && item.releaseDate!.isNotEmpty)
|
if (releaseDate != null && releaseDate!.isNotEmpty)
|
||||||
_MetadataItem('Release date', item.releaseDate!),
|
_MetadataItem('Release date', releaseDate!),
|
||||||
if (item.isrc != null && item.isrc!.isNotEmpty)
|
if (isrc != null && isrc!.isNotEmpty)
|
||||||
_MetadataItem('ISRC', item.isrc!),
|
_MetadataItem('ISRC', isrc!),
|
||||||
if (item.spotifyId != null && item.spotifyId!.isNotEmpty)
|
];
|
||||||
_MetadataItem('Spotify ID', item.spotifyId!),
|
|
||||||
if (item.quality != null && item.quality!.isNotEmpty)
|
if (item.spotifyId != null && item.spotifyId!.isNotEmpty) {
|
||||||
_MetadataItem('Quality', _formatQuality(item.quality!)),
|
final isDeezer = item.spotifyId!.contains('deezer');
|
||||||
|
final cleanId = item.spotifyId!.replaceAll('deezer:', '');
|
||||||
|
items.add(_MetadataItem(isDeezer ? 'Deezer ID' : 'Spotify ID', cleanId));
|
||||||
|
}
|
||||||
|
|
||||||
|
items.addAll([
|
||||||
_MetadataItem('Service', item.service.toUpperCase()),
|
_MetadataItem('Service', item.service.toUpperCase()),
|
||||||
_MetadataItem('Downloaded', _formatFullDate(item.downloadedAt)),
|
_MetadataItem('Downloaded', _formatFullDate(item.downloadedAt)),
|
||||||
];
|
]);
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: items.map((metadata) {
|
children: items.map((metadata) {
|
||||||
@@ -476,32 +514,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
return '$minutes:${secs.toString().padLeft(2, '0')}';
|
return '$minutes:${secs.toString().padLeft(2, '0')}';
|
||||||
}
|
}
|
||||||
|
|
||||||
String _formatQuality(String quality) {
|
|
||||||
switch (quality) {
|
|
||||||
case 'LOSSLESS':
|
|
||||||
return 'Lossless (16-bit)';
|
|
||||||
case 'HI_RES':
|
|
||||||
return 'Hi-Res (24-bit)';
|
|
||||||
case 'HI_RES_LOSSLESS':
|
|
||||||
return 'Hi-Res Lossless (24-bit)';
|
|
||||||
default:
|
|
||||||
return quality;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String _formatQualityShort(String quality) {
|
|
||||||
switch (quality) {
|
|
||||||
case 'LOSSLESS':
|
|
||||||
return '16-bit';
|
|
||||||
case 'HI_RES':
|
|
||||||
return '24-bit';
|
|
||||||
case 'HI_RES_LOSSLESS':
|
|
||||||
return 'Hi-Res';
|
|
||||||
default:
|
|
||||||
return quality;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildFileInfoCard(BuildContext context, ColorScheme colorScheme, bool fileExists, int? fileSize) {
|
Widget _buildFileInfoCard(BuildContext context, ColorScheme colorScheme, bool fileExists, int? fileSize) {
|
||||||
final fileName = item.filePath.split(Platform.pathSeparator).last;
|
final fileName = item.filePath.split(Platform.pathSeparator).last;
|
||||||
final fileExtension = fileName.contains('.') ? fileName.split('.').last.toUpperCase() : 'Unknown';
|
final fileExtension = fileName.contains('.') ? fileName.split('.').last.toUpperCase() : 'Unknown';
|
||||||
@@ -570,7 +582,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (item.quality != null)
|
if (bitDepth != null && sampleRate != null)
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -578,7 +590,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
_formatQualityShort(item.quality!),
|
'$bitDepth-bit/${(sampleRate! / 1000).toStringAsFixed(1)}kHz',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: colorScheme.onTertiaryContainer,
|
color: colorScheme.onTertiaryContainer,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
@@ -891,7 +903,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(Icons.delete, color: colorScheme.error),
|
leading: Icon(Icons.delete, color: colorScheme.error),
|
||||||
title: Text('Remove from history', style: TextStyle(color: colorScheme.error)),
|
title: Text('Remove from device', style: TextStyle(color: colorScheme.error)),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
_confirmDelete(context, ref, colorScheme);
|
_confirmDelete(context, ref, colorScheme);
|
||||||
@@ -908,10 +920,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: const Text('Remove from history?'),
|
title: const Text('Remove from device?'),
|
||||||
content: const Text(
|
content: const Text(
|
||||||
'This will remove the track from your download history. '
|
'This will permanently delete the downloaded file and remove it from your history.',
|
||||||
'The downloaded file will not be deleted.',
|
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
@@ -919,12 +930,26 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
child: const Text('Cancel'),
|
child: const Text('Cancel'),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () async {
|
||||||
|
// Delete the file first
|
||||||
|
try {
|
||||||
|
final file = File(item.filePath);
|
||||||
|
if (await file.exists()) {
|
||||||
|
await file.delete();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Failed to delete file: $e');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from history
|
||||||
ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id);
|
ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id);
|
||||||
Navigator.pop(context); // Close dialog
|
|
||||||
Navigator.pop(context); // Go back to history
|
if (context.mounted) {
|
||||||
|
Navigator.pop(context); // Close dialog
|
||||||
|
Navigator.pop(context); // Go back to history
|
||||||
|
}
|
||||||
},
|
},
|
||||||
child: Text('Remove', style: TextStyle(color: colorScheme.error)),
|
child: Text('Delete', style: TextStyle(color: colorScheme.error)),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'package:spotiflac_android/models/track.dart';
|
||||||
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
|
|
||||||
|
class CsvImportService {
|
||||||
|
static final _log = AppLogger('CsvImportService');
|
||||||
|
|
||||||
|
/// Pick and parse CSV file, then enrich metadata from Deezer
|
||||||
|
/// [onProgress] callback receives (current, total) for progress updates
|
||||||
|
static Future<List<Track>> pickAndParseCsv({
|
||||||
|
void Function(int current, int total)? onProgress,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final FilePickerResult? result = await FilePicker.platform.pickFiles(
|
||||||
|
type: FileType.custom,
|
||||||
|
allowedExtensions: ['csv'],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result != null && result.files.single.path != null) {
|
||||||
|
final file = File(result.files.single.path!);
|
||||||
|
final content = await file.readAsString();
|
||||||
|
final tracks = _parseCsv(content);
|
||||||
|
|
||||||
|
// Enrich tracks with metadata from Deezer (cover URL, duration, etc.)
|
||||||
|
if (tracks.isNotEmpty) {
|
||||||
|
return await _enrichTracksMetadata(tracks, onProgress: onProgress);
|
||||||
|
}
|
||||||
|
return tracks;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_log.e('Error picking/parsing CSV: $e');
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enrich tracks with metadata from Deezer using ISRC or search
|
||||||
|
/// This fetches cover URL, duration, and other metadata that CSV doesn't have
|
||||||
|
static Future<List<Track>> _enrichTracksMetadata(
|
||||||
|
List<Track> tracks, {
|
||||||
|
void Function(int current, int total)? onProgress,
|
||||||
|
}) async {
|
||||||
|
_log.i('Enriching metadata for ${tracks.length} tracks from Deezer...');
|
||||||
|
final enrichedTracks = <Track>[];
|
||||||
|
|
||||||
|
for (int i = 0; i < tracks.length; i++) {
|
||||||
|
final track = tracks[i];
|
||||||
|
onProgress?.call(i + 1, tracks.length);
|
||||||
|
|
||||||
|
// Only enrich if missing cover/duration
|
||||||
|
if (track.coverUrl == null || track.duration == 0) {
|
||||||
|
Map<String, dynamic>? trackData;
|
||||||
|
|
||||||
|
// Try ISRC first if available
|
||||||
|
if (track.isrc != null && track.isrc!.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
trackData = await PlatformBridge.searchDeezerByISRC(track.isrc!);
|
||||||
|
_log.d('ISRC enrichment success for ${track.name}');
|
||||||
|
} catch (e) {
|
||||||
|
_log.w('ISRC search failed for ${track.name}, trying text search...');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to text search if ISRC failed or not available
|
||||||
|
if (trackData == null) {
|
||||||
|
try {
|
||||||
|
final query = '${track.artistName} ${track.name}';
|
||||||
|
final searchResult = await PlatformBridge.searchDeezerAll(query, trackLimit: 5);
|
||||||
|
|
||||||
|
if (searchResult.containsKey('tracks')) {
|
||||||
|
final tracksList = searchResult['tracks'] as List<dynamic>?;
|
||||||
|
if (tracksList != null && tracksList.isNotEmpty) {
|
||||||
|
// Find best match by comparing names
|
||||||
|
for (final result in tracksList) {
|
||||||
|
final resultMap = result as Map<String, dynamic>;
|
||||||
|
final resultName = (resultMap['name'] as String?)?.toLowerCase() ?? '';
|
||||||
|
final trackNameLower = track.name.toLowerCase();
|
||||||
|
|
||||||
|
// Check if track name matches (contains or equals)
|
||||||
|
if (resultName.contains(trackNameLower) || trackNameLower.contains(resultName)) {
|
||||||
|
trackData = resultMap;
|
||||||
|
_log.d('Text search match for ${track.name}: $resultName');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no exact match, use first result
|
||||||
|
if (trackData == null && tracksList.isNotEmpty) {
|
||||||
|
trackData = tracksList.first as Map<String, dynamic>;
|
||||||
|
_log.d('Using first search result for ${track.name}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_log.w('Text search also failed for ${track.name}: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply enriched data if found
|
||||||
|
if (trackData != null) {
|
||||||
|
final coverUrl = trackData['images'] as String?;
|
||||||
|
final durationMs = trackData['duration_ms'] as int? ?? 0;
|
||||||
|
final deezerIdRaw = trackData['spotify_id'] as String?;
|
||||||
|
|
||||||
|
enrichedTracks.add(Track(
|
||||||
|
id: deezerIdRaw ?? track.id,
|
||||||
|
name: trackData['name'] as String? ?? track.name,
|
||||||
|
artistName: trackData['artists'] as String? ?? track.artistName,
|
||||||
|
albumName: trackData['album_name'] as String? ?? track.albumName,
|
||||||
|
albumArtist: trackData['album_artist'] as String?,
|
||||||
|
coverUrl: coverUrl ?? track.coverUrl,
|
||||||
|
isrc: trackData['isrc'] as String? ?? track.isrc,
|
||||||
|
duration: durationMs > 0 ? durationMs ~/ 1000 : track.duration,
|
||||||
|
trackNumber: trackData['track_number'] as int? ?? track.trackNumber,
|
||||||
|
discNumber: trackData['disc_number'] as int? ?? track.discNumber,
|
||||||
|
releaseDate: trackData['release_date'] as String? ?? track.releaseDate,
|
||||||
|
));
|
||||||
|
|
||||||
|
_log.d('Enriched: ${track.name} - cover: ${coverUrl != null}, duration: ${durationMs ~/ 1000}s');
|
||||||
|
|
||||||
|
// Small delay to avoid rate limiting
|
||||||
|
if (i < tracks.length - 1) {
|
||||||
|
await Future.delayed(const Duration(milliseconds: 100));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep original track if enrichment failed or not needed
|
||||||
|
enrichedTracks.add(track);
|
||||||
|
}
|
||||||
|
|
||||||
|
_log.i('Enrichment complete: ${enrichedTracks.length} tracks');
|
||||||
|
return enrichedTracks;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<Track> _parseCsv(String content) {
|
||||||
|
final List<Track> tracks = [];
|
||||||
|
final lines = content.split(RegExp(r'\r\n|\r|\n')); // Handle various newline formats
|
||||||
|
if (lines.isEmpty) return tracks;
|
||||||
|
|
||||||
|
// Detect headers line (assume first non-empty line)
|
||||||
|
int startIdx = 0;
|
||||||
|
while (startIdx < lines.length && lines[startIdx].trim().isEmpty) {
|
||||||
|
startIdx++;
|
||||||
|
}
|
||||||
|
if (startIdx >= lines.length) return tracks;
|
||||||
|
|
||||||
|
final headers = _parseLine(lines[startIdx]);
|
||||||
|
final colMap = <String, int>{};
|
||||||
|
for (int i = 0; i < headers.length; i++) {
|
||||||
|
// Normalize header: lowercase, trim, remove quotes
|
||||||
|
String h = _cleanValue(headers[i]).toLowerCase();
|
||||||
|
colMap[h] = i;
|
||||||
|
}
|
||||||
|
|
||||||
|
_log.d('CSV Headers: ${colMap.keys.toList()}');
|
||||||
|
|
||||||
|
// Parse rows
|
||||||
|
for (int i = startIdx + 1; i < lines.length; i++) {
|
||||||
|
final line = lines[i].trim();
|
||||||
|
if (line.isEmpty) continue;
|
||||||
|
|
||||||
|
final values = _parseLine(line);
|
||||||
|
|
||||||
|
// Helper to get value securely
|
||||||
|
String? getVal(List<String> keys) {
|
||||||
|
return _getValue(values, colMap, keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? trackName = getVal(['track name', 'track', 'name', 'title']);
|
||||||
|
String? artistName = getVal(['artist name', 'artist']);
|
||||||
|
String? albumName = getVal(['album name', 'album']);
|
||||||
|
String? isrc = getVal(['isrc']); // Often formatted with leading/trailing quotes
|
||||||
|
String? spotifyId = getVal(['spotify - id', 'spotify id', 'id', 'uri']); // Uri might need parsing
|
||||||
|
|
||||||
|
// If 'spotify uri' contains the id: 'spotify:track:ID'
|
||||||
|
if (spotifyId != null && spotifyId.startsWith('spotify:track:')) {
|
||||||
|
spotifyId = spotifyId.replaceAll('spotify:track:', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic validation: Need at least name and artist, OR a spotify ID
|
||||||
|
if ((trackName != null && trackName.isNotEmpty && artistName != null) || (spotifyId != null && spotifyId.isNotEmpty)) {
|
||||||
|
tracks.add(Track(
|
||||||
|
id: spotifyId ?? 'csv_${DateTime.now().millisecondsSinceEpoch}_$i',
|
||||||
|
name: trackName ?? 'Unknown Track',
|
||||||
|
artistName: artistName ?? 'Unknown Artist',
|
||||||
|
albumName: albumName ?? 'Unknown Album',
|
||||||
|
isrc: isrc,
|
||||||
|
duration: 0, // Will be updated by enrichment later
|
||||||
|
coverUrl: null, // Will be fetched by enrichment
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_log.i('Parsed ${tracks.length} tracks from CSV');
|
||||||
|
return tracks;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? _getValue(List<String> values, Map<String, int> colMap, List<String> possibleKeys) {
|
||||||
|
for (final key in possibleKeys) {
|
||||||
|
if (colMap.containsKey(key)) {
|
||||||
|
final index = colMap[key]!;
|
||||||
|
if (index < values.length) {
|
||||||
|
return _cleanValue(values[index]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _cleanValue(String val) {
|
||||||
|
val = val.trim();
|
||||||
|
if (val.startsWith('"') && val.endsWith('"') && val.length >= 2) {
|
||||||
|
val = val.substring(1, val.length - 1);
|
||||||
|
}
|
||||||
|
// Handle double quotes escape in CSV ("" -> ")
|
||||||
|
val = val.replaceAll('""', '"');
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Robust CSV Line Parser
|
||||||
|
static List<String> _parseLine(String line) {
|
||||||
|
final List<String> result = [];
|
||||||
|
bool inQuote = false;
|
||||||
|
StringBuffer buffer = StringBuffer();
|
||||||
|
|
||||||
|
for (int i=0; i<line.length; i++) {
|
||||||
|
String char = line[i];
|
||||||
|
if (char == '"') {
|
||||||
|
// Look ahead to check for escaped quote
|
||||||
|
if (i + 1 < line.length && line[i+1] == '"') {
|
||||||
|
buffer.write('"'); // Keep format for now, _cleanValue handles unescaping logic differently...
|
||||||
|
// Wait, standard CSV: "Thumb ""Up""" -> Thumb "Up"
|
||||||
|
// My _cleanValue handles it, so I should just preserve raw content here mostly,
|
||||||
|
// BUT I need to know if " toggles inQuote.
|
||||||
|
// Escaped "" does NOT toggle inQuote mode effectively (it counts as literal char inside quote).
|
||||||
|
buffer.write('"'); // Write 1st quote
|
||||||
|
i++; // Skip next quote char loop
|
||||||
|
buffer.write('"'); // Write 2nd quote
|
||||||
|
} else {
|
||||||
|
inQuote = !inQuote;
|
||||||
|
buffer.write(char);
|
||||||
|
}
|
||||||
|
} else if (char == ',' && !inQuote) {
|
||||||
|
result.add(buffer.toString());
|
||||||
|
buffer.clear();
|
||||||
|
} else {
|
||||||
|
buffer.write(char);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.add(buffer.toString());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
|
|
||||||
|
final _log = AppLogger('PlatformBridge');
|
||||||
|
|
||||||
/// Bridge to communicate with Go backend via platform channels
|
/// Bridge to communicate with Go backend via platform channels
|
||||||
class PlatformBridge {
|
class PlatformBridge {
|
||||||
@@ -7,18 +10,21 @@ class PlatformBridge {
|
|||||||
|
|
||||||
/// Parse and validate Spotify URL
|
/// Parse and validate Spotify URL
|
||||||
static Future<Map<String, dynamic>> parseSpotifyUrl(String url) async {
|
static Future<Map<String, dynamic>> parseSpotifyUrl(String url) async {
|
||||||
|
_log.d('parseSpotifyUrl: $url');
|
||||||
final result = await _channel.invokeMethod('parseSpotifyUrl', {'url': url});
|
final result = await _channel.invokeMethod('parseSpotifyUrl', {'url': url});
|
||||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get Spotify metadata from URL
|
/// Get Spotify metadata from URL
|
||||||
static Future<Map<String, dynamic>> getSpotifyMetadata(String url) async {
|
static Future<Map<String, dynamic>> getSpotifyMetadata(String url) async {
|
||||||
|
_log.d('getSpotifyMetadata: $url');
|
||||||
final result = await _channel.invokeMethod('getSpotifyMetadata', {'url': url});
|
final result = await _channel.invokeMethod('getSpotifyMetadata', {'url': url});
|
||||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Search Spotify
|
/// Search Spotify
|
||||||
static Future<Map<String, dynamic>> searchSpotify(String query, {int limit = 10}) async {
|
static Future<Map<String, dynamic>> searchSpotify(String query, {int limit = 10}) async {
|
||||||
|
_log.d('searchSpotify: "$query" (limit: $limit)');
|
||||||
final result = await _channel.invokeMethod('searchSpotify', {
|
final result = await _channel.invokeMethod('searchSpotify', {
|
||||||
'query': query,
|
'query': query,
|
||||||
'limit': limit,
|
'limit': limit,
|
||||||
@@ -28,6 +34,7 @@ class PlatformBridge {
|
|||||||
|
|
||||||
/// Search Spotify for tracks and artists
|
/// Search Spotify for tracks and artists
|
||||||
static Future<Map<String, dynamic>> searchSpotifyAll(String query, {int trackLimit = 15, int artistLimit = 3}) async {
|
static Future<Map<String, dynamic>> searchSpotifyAll(String query, {int trackLimit = 15, int artistLimit = 3}) async {
|
||||||
|
_log.d('searchSpotifyAll: "$query"');
|
||||||
final result = await _channel.invokeMethod('searchSpotifyAll', {
|
final result = await _channel.invokeMethod('searchSpotifyAll', {
|
||||||
'query': query,
|
'query': query,
|
||||||
'track_limit': trackLimit,
|
'track_limit': trackLimit,
|
||||||
@@ -38,6 +45,7 @@ class PlatformBridge {
|
|||||||
|
|
||||||
/// Check track availability on streaming services
|
/// Check track availability on streaming services
|
||||||
static Future<Map<String, dynamic>> checkAvailability(String spotifyId, String isrc) async {
|
static Future<Map<String, dynamic>> checkAvailability(String spotifyId, String isrc) async {
|
||||||
|
_log.d('checkAvailability: $spotifyId (ISRC: $isrc)');
|
||||||
final result = await _channel.invokeMethod('checkAvailability', {
|
final result = await _channel.invokeMethod('checkAvailability', {
|
||||||
'spotify_id': spotifyId,
|
'spotify_id': spotifyId,
|
||||||
'isrc': isrc,
|
'isrc': isrc,
|
||||||
@@ -67,6 +75,7 @@ class PlatformBridge {
|
|||||||
String? itemId,
|
String? itemId,
|
||||||
int durationMs = 0,
|
int durationMs = 0,
|
||||||
}) async {
|
}) async {
|
||||||
|
_log.i('downloadTrack: "$trackName" by $artistName via $service');
|
||||||
final request = jsonEncode({
|
final request = jsonEncode({
|
||||||
'isrc': isrc,
|
'isrc': isrc,
|
||||||
'service': service,
|
'service': service,
|
||||||
@@ -90,7 +99,13 @@ class PlatformBridge {
|
|||||||
});
|
});
|
||||||
|
|
||||||
final result = await _channel.invokeMethod('downloadTrack', request);
|
final result = await _channel.invokeMethod('downloadTrack', request);
|
||||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
final response = jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
|
if (response['success'] == true) {
|
||||||
|
_log.i('Download success: ${response['file_path']}');
|
||||||
|
} else {
|
||||||
|
_log.w('Download failed: ${response['error']}');
|
||||||
|
}
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Download with automatic fallback to other services
|
/// Download with automatic fallback to other services
|
||||||
@@ -111,10 +126,11 @@ class PlatformBridge {
|
|||||||
int discNumber = 1,
|
int discNumber = 1,
|
||||||
int totalTracks = 1,
|
int totalTracks = 1,
|
||||||
String? releaseDate,
|
String? releaseDate,
|
||||||
String preferredService = 'qobuz',
|
String preferredService = 'tidal',
|
||||||
String? itemId,
|
String? itemId,
|
||||||
int durationMs = 0,
|
int durationMs = 0,
|
||||||
}) async {
|
}) async {
|
||||||
|
_log.i('downloadWithFallback: "$trackName" by $artistName (preferred: $preferredService)');
|
||||||
final request = jsonEncode({
|
final request = jsonEncode({
|
||||||
'isrc': isrc,
|
'isrc': isrc,
|
||||||
'service': preferredService,
|
'service': preferredService,
|
||||||
@@ -138,7 +154,22 @@ class PlatformBridge {
|
|||||||
});
|
});
|
||||||
|
|
||||||
final result = await _channel.invokeMethod('downloadWithFallback', request);
|
final result = await _channel.invokeMethod('downloadWithFallback', request);
|
||||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
final response = jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
|
if (response['success'] == true) {
|
||||||
|
final service = response['service'] ?? 'unknown';
|
||||||
|
final filePath = response['file_path'] ?? '';
|
||||||
|
final bitDepth = response['actual_bit_depth'];
|
||||||
|
final sampleRate = response['actual_sample_rate'];
|
||||||
|
final qualityStr = bitDepth != null && sampleRate != null
|
||||||
|
? ' ($bitDepth-bit/${(sampleRate / 1000).toStringAsFixed(1)}kHz)'
|
||||||
|
: '';
|
||||||
|
_log.i('Download success via $service$qualityStr: $filePath');
|
||||||
|
} else {
|
||||||
|
final error = response['error'] ?? 'Unknown error';
|
||||||
|
final errorType = response['error_type'] ?? '';
|
||||||
|
_log.e('Download failed: $error (type: $errorType)');
|
||||||
|
}
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get download progress (legacy single download)
|
/// Get download progress (legacy single download)
|
||||||
@@ -248,6 +279,16 @@ class PlatformBridge {
|
|||||||
await _channel.invokeMethod('cleanupConnections');
|
await _channel.invokeMethod('cleanupConnections');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read metadata directly from a FLAC file
|
||||||
|
/// Returns all embedded metadata (title, artist, album, track number, etc.)
|
||||||
|
/// This reads from the actual file, not from cached/database data
|
||||||
|
static Future<Map<String, dynamic>> readFileMetadata(String filePath) async {
|
||||||
|
final result = await _channel.invokeMethod('readFileMetadata', {
|
||||||
|
'file_path': filePath,
|
||||||
|
});
|
||||||
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
/// Start foreground download service to keep downloads running in background
|
/// Start foreground download service to keep downloads running in background
|
||||||
static Future<void> startDownloadService({
|
static Future<void> startDownloadService({
|
||||||
String trackName = '',
|
String trackName = '',
|
||||||
@@ -335,6 +376,9 @@ class PlatformBridge {
|
|||||||
'resource_type': resourceType,
|
'resource_type': resourceType,
|
||||||
'resource_id': resourceId,
|
'resource_id': resourceId,
|
||||||
});
|
});
|
||||||
|
if (result == null) {
|
||||||
|
throw Exception('getDeezerMetadata returned null for $resourceType:$resourceId');
|
||||||
|
}
|
||||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,4 +408,35 @@ class PlatformBridge {
|
|||||||
final result = await _channel.invokeMethod('getSpotifyMetadataWithFallback', {'url': url});
|
final result = await _channel.invokeMethod('getSpotifyMetadataWithFallback', {'url': url});
|
||||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== GO BACKEND LOGS ====================
|
||||||
|
|
||||||
|
/// Get all logs from Go backend
|
||||||
|
static Future<List<Map<String, dynamic>>> getGoLogs() async {
|
||||||
|
final result = await _channel.invokeMethod('getLogs');
|
||||||
|
final logs = jsonDecode(result as String) as List<dynamic>;
|
||||||
|
return logs.map((e) => e as Map<String, dynamic>).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get logs since a specific index (for incremental updates)
|
||||||
|
static Future<Map<String, dynamic>> getGoLogsSince(int index) async {
|
||||||
|
final result = await _channel.invokeMethod('getLogsSince', {'index': index});
|
||||||
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear Go backend logs
|
||||||
|
static Future<void> clearGoLogs() async {
|
||||||
|
await _channel.invokeMethod('clearLogs');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get Go backend log count
|
||||||
|
static Future<int> getGoLogCount() async {
|
||||||
|
final result = await _channel.invokeMethod('getLogCount');
|
||||||
|
return result as int;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enable or disable Go backend logging
|
||||||
|
static Future<void> setGoLoggingEnabled(bool enabled) async {
|
||||||
|
await _channel.invokeMethod('setLoggingEnabled', {'enabled': enabled});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+105
-78
@@ -7,11 +7,9 @@ class AppTheme {
|
|||||||
static const Color defaultSeedColor = Color(kDefaultSeedColor);
|
static const Color defaultSeedColor = Color(kDefaultSeedColor);
|
||||||
|
|
||||||
/// Create light theme
|
/// Create light theme
|
||||||
static ThemeData light({
|
static ThemeData light({ColorScheme? dynamicScheme, Color? seedColor}) {
|
||||||
ColorScheme? dynamicScheme,
|
final scheme =
|
||||||
Color? seedColor,
|
dynamicScheme ??
|
||||||
}) {
|
|
||||||
final scheme = dynamicScheme ??
|
|
||||||
ColorScheme.fromSeed(
|
ColorScheme.fromSeed(
|
||||||
seedColor: seedColor ?? defaultSeedColor,
|
seedColor: seedColor ?? defaultSeedColor,
|
||||||
brightness: Brightness.light,
|
brightness: Brightness.light,
|
||||||
@@ -45,7 +43,8 @@ class AppTheme {
|
|||||||
Color? seedColor,
|
Color? seedColor,
|
||||||
bool isAmoled = false,
|
bool isAmoled = false,
|
||||||
}) {
|
}) {
|
||||||
final scheme = dynamicScheme ??
|
final scheme =
|
||||||
|
dynamicScheme ??
|
||||||
ColorScheme.fromSeed(
|
ColorScheme.fromSeed(
|
||||||
seedColor: seedColor ?? defaultSeedColor,
|
seedColor: seedColor ?? defaultSeedColor,
|
||||||
brightness: Brightness.dark,
|
brightness: Brightness.dark,
|
||||||
@@ -75,34 +74,41 @@ class AppTheme {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// AppBar theme
|
/// AppBar theme
|
||||||
static AppBarTheme _appBarTheme(ColorScheme scheme, {bool isAmoled = false}) => AppBarTheme(
|
static AppBarTheme _appBarTheme(
|
||||||
elevation: 0,
|
ColorScheme scheme, {
|
||||||
scrolledUnderElevation: isAmoled ? 0 : 3,
|
bool isAmoled = false,
|
||||||
backgroundColor: isAmoled ? Colors.black : scheme.surface,
|
}) => AppBarTheme(
|
||||||
foregroundColor: scheme.onSurface,
|
elevation: 0,
|
||||||
surfaceTintColor: isAmoled ? Colors.transparent : scheme.surfaceTint,
|
scrolledUnderElevation: isAmoled ? 0 : 3,
|
||||||
centerTitle: true,
|
backgroundColor: isAmoled ? Colors.black : scheme.surface,
|
||||||
titleTextStyle: TextStyle(
|
foregroundColor: scheme.onSurface,
|
||||||
color: scheme.onSurface,
|
surfaceTintColor: isAmoled ? Colors.transparent : scheme.surfaceTint,
|
||||||
fontSize: 22,
|
centerTitle: true,
|
||||||
fontWeight: FontWeight.w500,
|
titleTextStyle: TextStyle(
|
||||||
),
|
color: scheme.onSurface,
|
||||||
);
|
fontSize: 22,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
/// Card theme
|
/// Card theme
|
||||||
static CardThemeData _cardTheme(ColorScheme scheme) => CardThemeData(
|
static CardThemeData _cardTheme(ColorScheme scheme) => CardThemeData(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
shape: RoundedRectangleBorder(
|
||||||
color: scheme.surfaceContainerLow,
|
borderRadius: BorderRadius.circular(16),
|
||||||
surfaceTintColor: scheme.surfaceTint,
|
), // 12 -> 16
|
||||||
);
|
color: scheme.surfaceContainerLow,
|
||||||
|
surfaceTintColor: scheme.surfaceTint,
|
||||||
|
);
|
||||||
|
|
||||||
/// Elevated button theme
|
/// Elevated button theme
|
||||||
static ElevatedButtonThemeData _elevatedButtonTheme(ColorScheme scheme) =>
|
static ElevatedButtonThemeData _elevatedButtonTheme(ColorScheme scheme) =>
|
||||||
ElevatedButtonThemeData(
|
ElevatedButtonThemeData(
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
elevation: 1,
|
elevation: 1,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
), // 20 -> 16
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -111,7 +117,9 @@ class AppTheme {
|
|||||||
static FilledButtonThemeData _filledButtonTheme(ColorScheme scheme) =>
|
static FilledButtonThemeData _filledButtonTheme(ColorScheme scheme) =>
|
||||||
FilledButtonThemeData(
|
FilledButtonThemeData(
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
), // 20 -> 16
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -120,7 +128,9 @@ class AppTheme {
|
|||||||
static OutlinedButtonThemeData _outlinedButtonTheme(ColorScheme scheme) =>
|
static OutlinedButtonThemeData _outlinedButtonTheme(ColorScheme scheme) =>
|
||||||
OutlinedButtonThemeData(
|
OutlinedButtonThemeData(
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
), // 20 -> 16
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -129,7 +139,9 @@ class AppTheme {
|
|||||||
static TextButtonThemeData _textButtonTheme(ColorScheme scheme) =>
|
static TextButtonThemeData _textButtonTheme(ColorScheme scheme) =>
|
||||||
TextButtonThemeData(
|
TextButtonThemeData(
|
||||||
style: TextButton.styleFrom(
|
style: TextButton.styleFrom(
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
), // 20 -> 16
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -147,52 +159,63 @@ class AppTheme {
|
|||||||
static InputDecorationTheme _inputDecorationTheme(ColorScheme scheme) =>
|
static InputDecorationTheme _inputDecorationTheme(ColorScheme scheme) =>
|
||||||
InputDecorationTheme(
|
InputDecorationTheme(
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: scheme.surfaceContainerHighest,
|
fillColor: scheme.surfaceContainerHighest.withValues(
|
||||||
|
alpha: 0.3,
|
||||||
|
), // Added transparency
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(16), // 12 -> 16
|
||||||
borderSide: BorderSide.none,
|
borderSide: BorderSide.none,
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(16), // 12 -> 16
|
||||||
borderSide: BorderSide.none,
|
borderSide: BorderSide.none,
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(16), // 12 -> 16
|
||||||
borderSide: BorderSide(color: scheme.primary, width: 2),
|
borderSide: BorderSide(color: scheme.primary, width: 2),
|
||||||
),
|
),
|
||||||
errorBorder: OutlineInputBorder(
|
errorBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(16), // 12 -> 16
|
||||||
borderSide: BorderSide(color: scheme.error, width: 1),
|
borderSide: BorderSide(color: scheme.error, width: 1),
|
||||||
),
|
),
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 20,
|
||||||
|
vertical: 16,
|
||||||
|
), // consistent padding
|
||||||
);
|
);
|
||||||
|
|
||||||
/// List tile theme
|
/// List tile theme
|
||||||
static ListTileThemeData _listTileTheme(ColorScheme scheme) => ListTileThemeData(
|
static ListTileThemeData _listTileTheme(ColorScheme scheme) =>
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
ListTileThemeData(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
), // 12 -> 16
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Dialog theme
|
/// Dialog theme
|
||||||
static DialogThemeData _dialogTheme(ColorScheme scheme) => DialogThemeData(
|
static DialogThemeData _dialogTheme(ColorScheme scheme) => DialogThemeData(
|
||||||
elevation: 6,
|
elevation: 6,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
|
||||||
backgroundColor: scheme.surfaceContainerHigh,
|
backgroundColor: scheme.surfaceContainerHigh,
|
||||||
surfaceTintColor: scheme.surfaceTint,
|
surfaceTintColor: scheme.surfaceTint,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Navigation bar theme
|
/// Navigation bar theme
|
||||||
static NavigationBarThemeData _navigationBarTheme(ColorScheme scheme, {bool isAmoled = false}) =>
|
static NavigationBarThemeData _navigationBarTheme(
|
||||||
NavigationBarThemeData(
|
ColorScheme scheme, {
|
||||||
elevation: 0,
|
bool isAmoled = false,
|
||||||
backgroundColor: isAmoled ? Colors.black : scheme.surfaceContainer,
|
}) => NavigationBarThemeData(
|
||||||
indicatorColor: scheme.secondaryContainer,
|
elevation: 0,
|
||||||
surfaceTintColor: isAmoled ? Colors.transparent : scheme.surfaceTint,
|
backgroundColor: isAmoled ? Colors.black : scheme.surfaceContainer,
|
||||||
labelBehavior: NavigationDestinationLabelBehavior.alwaysShow,
|
indicatorColor: scheme.secondaryContainer,
|
||||||
);
|
surfaceTintColor: isAmoled ? Colors.transparent : scheme.surfaceTint,
|
||||||
|
labelBehavior: NavigationDestinationLabelBehavior.alwaysShow,
|
||||||
|
);
|
||||||
|
|
||||||
/// SnackBar theme
|
/// SnackBar theme
|
||||||
static SnackBarThemeData _snackBarTheme(ColorScheme scheme) => SnackBarThemeData(
|
static SnackBarThemeData _snackBarTheme(ColorScheme scheme) =>
|
||||||
|
SnackBarThemeData(
|
||||||
behavior: SnackBarBehavior.floating,
|
behavior: SnackBarBehavior.floating,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
backgroundColor: scheme.inverseSurface,
|
backgroundColor: scheme.inverseSurface,
|
||||||
@@ -200,40 +223,44 @@ class AppTheme {
|
|||||||
);
|
);
|
||||||
|
|
||||||
/// Progress indicator theme
|
/// Progress indicator theme
|
||||||
static ProgressIndicatorThemeData _progressIndicatorTheme(ColorScheme scheme) =>
|
static ProgressIndicatorThemeData _progressIndicatorTheme(
|
||||||
ProgressIndicatorThemeData(
|
ColorScheme scheme,
|
||||||
color: scheme.primary,
|
) => ProgressIndicatorThemeData(
|
||||||
linearTrackColor: scheme.surfaceContainerHighest,
|
color: scheme.primary,
|
||||||
circularTrackColor: scheme.surfaceContainerHighest,
|
linearTrackColor: scheme.surfaceContainerHighest,
|
||||||
);
|
circularTrackColor: scheme.surfaceContainerHighest,
|
||||||
|
);
|
||||||
|
|
||||||
/// Switch theme
|
/// Switch theme
|
||||||
static SwitchThemeData _switchTheme(ColorScheme scheme) => SwitchThemeData(
|
static SwitchThemeData _switchTheme(ColorScheme scheme) => SwitchThemeData(
|
||||||
thumbColor: WidgetStateProperty.resolveWith((states) {
|
thumbColor: WidgetStateProperty.resolveWith((states) {
|
||||||
if (states.contains(WidgetState.selected)) {
|
if (states.contains(WidgetState.selected)) {
|
||||||
return scheme.onPrimary;
|
return scheme.onPrimary;
|
||||||
}
|
}
|
||||||
return scheme.outline;
|
return scheme.outline;
|
||||||
}),
|
}),
|
||||||
trackColor: WidgetStateProperty.resolveWith((states) {
|
trackColor: WidgetStateProperty.resolveWith((states) {
|
||||||
if (states.contains(WidgetState.selected)) {
|
if (states.contains(WidgetState.selected)) {
|
||||||
return scheme.primary;
|
return scheme.primary;
|
||||||
}
|
}
|
||||||
return scheme.surfaceContainerHighest;
|
return scheme.surfaceContainerHighest;
|
||||||
}),
|
}),
|
||||||
);
|
thumbIcon: WidgetStateProperty.resolveWith((states) {
|
||||||
|
if (states.contains(WidgetState.selected)) {
|
||||||
|
return Icon(Icons.check, color: scheme.primary);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
/// Chip theme
|
/// Chip theme
|
||||||
static ChipThemeData _chipTheme(ColorScheme scheme) => ChipThemeData(
|
static ChipThemeData _chipTheme(ColorScheme scheme) => ChipThemeData(
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
backgroundColor: scheme.surfaceContainerLow,
|
backgroundColor: scheme.surfaceContainerLow,
|
||||||
selectedColor: scheme.secondaryContainer,
|
selectedColor: scheme.secondaryContainer,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Divider theme
|
/// Divider theme
|
||||||
static DividerThemeData _dividerTheme(ColorScheme scheme) => DividerThemeData(
|
static DividerThemeData _dividerTheme(ColorScheme scheme) =>
|
||||||
color: scheme.outlineVariant,
|
DividerThemeData(color: scheme.outlineVariant, thickness: 1, space: 1);
|
||||||
thickness: 1,
|
|
||||||
space: 1,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+296
-7
@@ -1,7 +1,234 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:collection';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:logger/logger.dart';
|
import 'package:logger/logger.dart';
|
||||||
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
|
|
||||||
|
/// Log entry with timestamp and level
|
||||||
|
class LogEntry {
|
||||||
|
final DateTime timestamp;
|
||||||
|
final String level;
|
||||||
|
final String tag;
|
||||||
|
final String message;
|
||||||
|
final String? error;
|
||||||
|
final bool isFromGo; // Track if this log came from Go backend
|
||||||
|
|
||||||
|
LogEntry({
|
||||||
|
required this.timestamp,
|
||||||
|
required this.level,
|
||||||
|
required this.tag,
|
||||||
|
required this.message,
|
||||||
|
this.error,
|
||||||
|
this.isFromGo = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
String get formattedTime {
|
||||||
|
final h = timestamp.hour.toString().padLeft(2, '0');
|
||||||
|
final m = timestamp.minute.toString().padLeft(2, '0');
|
||||||
|
final s = timestamp.second.toString().padLeft(2, '0');
|
||||||
|
final ms = timestamp.millisecond.toString().padLeft(3, '0');
|
||||||
|
return '$h:$m:$s.$ms';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
final errorPart = error != null ? ' | $error' : '';
|
||||||
|
final goPart = isFromGo ? ' [Go]' : '';
|
||||||
|
return '[$formattedTime] [$level]$goPart [$tag] $message$errorPart';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Circular buffer for storing logs in memory
|
||||||
|
class LogBuffer extends ChangeNotifier {
|
||||||
|
static final LogBuffer _instance = LogBuffer._internal();
|
||||||
|
factory LogBuffer() => _instance;
|
||||||
|
LogBuffer._internal();
|
||||||
|
|
||||||
|
static const int maxEntries = 500;
|
||||||
|
final Queue<LogEntry> _entries = Queue<LogEntry>();
|
||||||
|
Timer? _goLogTimer;
|
||||||
|
int _lastGoLogIndex = 0;
|
||||||
|
|
||||||
|
/// Whether logging is enabled (controlled by settings)
|
||||||
|
/// User must enable "Detailed Logging" in settings to capture logs
|
||||||
|
static bool _loggingEnabled = false;
|
||||||
|
static bool get loggingEnabled => _loggingEnabled;
|
||||||
|
static set loggingEnabled(bool value) {
|
||||||
|
_loggingEnabled = value;
|
||||||
|
// Also notify Go backend about logging state
|
||||||
|
if (value) {
|
||||||
|
PlatformBridge.setGoLoggingEnabled(true).catchError((_) {});
|
||||||
|
} else {
|
||||||
|
PlatformBridge.setGoLoggingEnabled(false).catchError((_) {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<LogEntry> get entries => _entries.toList();
|
||||||
|
int get length => _entries.length;
|
||||||
|
|
||||||
|
void add(LogEntry entry) {
|
||||||
|
// Skip adding if logging is disabled (except for errors which are always logged)
|
||||||
|
if (!_loggingEnabled && entry.level != 'ERROR' && entry.level != 'FATAL') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_entries.length >= maxEntries) {
|
||||||
|
_entries.removeFirst();
|
||||||
|
}
|
||||||
|
_entries.add(entry);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start polling Go backend logs
|
||||||
|
void startGoLogPolling() {
|
||||||
|
_goLogTimer?.cancel();
|
||||||
|
_goLogTimer = Timer.periodic(const Duration(milliseconds: 500), (_) async {
|
||||||
|
await _fetchGoLogs();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop polling Go backend logs
|
||||||
|
void stopGoLogPolling() {
|
||||||
|
_goLogTimer?.cancel();
|
||||||
|
_goLogTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch logs from Go backend since last index
|
||||||
|
Future<void> _fetchGoLogs() async {
|
||||||
|
try {
|
||||||
|
final result = await PlatformBridge.getGoLogsSince(_lastGoLogIndex);
|
||||||
|
final logs = result['logs'] as List<dynamic>? ?? [];
|
||||||
|
final nextIndex = result['next_index'] as int? ?? _lastGoLogIndex;
|
||||||
|
|
||||||
|
for (final log in logs) {
|
||||||
|
final timestamp = log['timestamp'] as String? ?? '';
|
||||||
|
final level = log['level'] as String? ?? 'INFO';
|
||||||
|
final tag = log['tag'] as String? ?? 'Go';
|
||||||
|
final message = log['message'] as String? ?? '';
|
||||||
|
|
||||||
|
// Parse timestamp (format: "15:04:05.000")
|
||||||
|
DateTime parsedTime = DateTime.now();
|
||||||
|
if (timestamp.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
final parts = timestamp.split(':');
|
||||||
|
if (parts.length >= 3) {
|
||||||
|
final secParts = parts[2].split('.');
|
||||||
|
parsedTime = DateTime(
|
||||||
|
parsedTime.year, parsedTime.month, parsedTime.day,
|
||||||
|
int.parse(parts[0]), int.parse(parts[1]),
|
||||||
|
int.parse(secParts[0]),
|
||||||
|
secParts.length > 1 ? int.parse(secParts[1]) : 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Use current time if parsing fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
add(LogEntry(
|
||||||
|
timestamp: parsedTime,
|
||||||
|
level: level,
|
||||||
|
tag: tag,
|
||||||
|
message: message,
|
||||||
|
isFromGo: true,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
_lastGoLogIndex = nextIndex;
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore errors - Go backend might not be ready
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint('Failed to fetch Go logs: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void clear() {
|
||||||
|
_entries.clear();
|
||||||
|
_lastGoLogIndex = 0;
|
||||||
|
// Also clear Go backend logs
|
||||||
|
PlatformBridge.clearGoLogs().catchError((_) {});
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
String export() {
|
||||||
|
final buffer = StringBuffer();
|
||||||
|
buffer.writeln('SpotiFLAC Log Export');
|
||||||
|
buffer.writeln('Generated: ${DateTime.now().toIso8601String()}');
|
||||||
|
buffer.writeln('Entries: ${_entries.length}');
|
||||||
|
buffer.writeln('=' * 60);
|
||||||
|
buffer.writeln();
|
||||||
|
for (final entry in _entries) {
|
||||||
|
buffer.writeln(entry.toString());
|
||||||
|
}
|
||||||
|
return buffer.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<LogEntry> filter({String? level, String? tag, String? search}) {
|
||||||
|
return _entries.where((entry) {
|
||||||
|
if (level != null && level != 'ALL' && entry.level != level) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (tag != null && !entry.tag.toLowerCase().contains(tag.toLowerCase())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (search != null && search.isNotEmpty) {
|
||||||
|
final searchLower = search.toLowerCase();
|
||||||
|
return entry.message.toLowerCase().contains(searchLower) ||
|
||||||
|
entry.tag.toLowerCase().contains(searchLower) ||
|
||||||
|
(entry.error?.toLowerCase().contains(searchLower) ?? false);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Custom log output that writes to both console and buffer
|
||||||
|
class BufferedOutput extends LogOutput {
|
||||||
|
final String tag;
|
||||||
|
|
||||||
|
BufferedOutput(this.tag);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void output(OutputEvent event) {
|
||||||
|
// Print to console in debug mode
|
||||||
|
if (kDebugMode) {
|
||||||
|
for (final line in event.lines) {
|
||||||
|
debugPrint(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to buffer
|
||||||
|
final level = _levelToString(event.level);
|
||||||
|
final message = event.lines.join('\n');
|
||||||
|
|
||||||
|
LogBuffer().add(LogEntry(
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
level: level,
|
||||||
|
tag: tag,
|
||||||
|
message: message,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
String _levelToString(Level level) {
|
||||||
|
switch (level) {
|
||||||
|
case Level.debug:
|
||||||
|
return 'DEBUG';
|
||||||
|
case Level.info:
|
||||||
|
return 'INFO';
|
||||||
|
case Level.warning:
|
||||||
|
return 'WARN';
|
||||||
|
case Level.error:
|
||||||
|
return 'ERROR';
|
||||||
|
case Level.fatal:
|
||||||
|
return 'FATAL';
|
||||||
|
default:
|
||||||
|
return 'LOG';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Global logger instance for the app
|
/// Global logger instance for the app
|
||||||
/// Uses pretty printer in debug mode for readable output
|
|
||||||
final log = Logger(
|
final log = Logger(
|
||||||
printer: PrettyPrinter(
|
printer: PrettyPrinter(
|
||||||
methodCount: 0,
|
methodCount: 0,
|
||||||
@@ -15,14 +242,76 @@ final log = Logger(
|
|||||||
);
|
);
|
||||||
|
|
||||||
/// Logger with class/tag prefix for better traceability
|
/// Logger with class/tag prefix for better traceability
|
||||||
|
/// Now also writes to LogBuffer for in-app viewing
|
||||||
|
/// Works in both debug and release mode
|
||||||
class AppLogger {
|
class AppLogger {
|
||||||
final String _tag;
|
final String _tag;
|
||||||
|
late final Logger? _logger;
|
||||||
|
|
||||||
AppLogger(this._tag);
|
AppLogger(this._tag) {
|
||||||
|
// Only create Logger instance in debug mode
|
||||||
|
// In release mode, we write directly to LogBuffer
|
||||||
|
if (kDebugMode) {
|
||||||
|
_logger = Logger(
|
||||||
|
printer: SimplePrinter(printTime: false, colors: false),
|
||||||
|
output: BufferedOutput(_tag),
|
||||||
|
level: Level.debug,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
_logger = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void d(String message) => log.d('[$_tag] $message');
|
void _addToBuffer(String level, String message, {String? error}) {
|
||||||
void i(String message) => log.i('[$_tag] $message');
|
LogBuffer().add(LogEntry(
|
||||||
void w(String message) => log.w('[$_tag] $message');
|
timestamp: DateTime.now(),
|
||||||
void e(String message, [Object? error, StackTrace? stackTrace]) =>
|
level: level,
|
||||||
log.e('[$_tag] $message', error: error, stackTrace: stackTrace);
|
tag: _tag,
|
||||||
|
message: message,
|
||||||
|
error: error,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
void d(String message) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
_logger?.d(message);
|
||||||
|
} else {
|
||||||
|
// In release mode, write directly to buffer
|
||||||
|
_addToBuffer('DEBUG', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void i(String message) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
_logger?.i(message);
|
||||||
|
} else {
|
||||||
|
_addToBuffer('INFO', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void w(String message) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
_logger?.w(message);
|
||||||
|
} else {
|
||||||
|
_addToBuffer('WARN', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void e(String message, [Object? error, StackTrace? stackTrace]) {
|
||||||
|
if (error != null) {
|
||||||
|
_addToBuffer('ERROR', message, error: error.toString());
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint('[$_tag] ERROR: $message | $error');
|
||||||
|
if (stackTrace != null) {
|
||||||
|
debugPrint(stackTrace.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (kDebugMode) {
|
||||||
|
_logger?.e(message);
|
||||||
|
} else {
|
||||||
|
_addToBuffer('ERROR', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: spotiflac_android
|
name: spotiflac_android
|
||||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||||
publish_to: "none"
|
publish_to: "none"
|
||||||
version: 2.1.7+45
|
version: 2.2.7+49
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.0
|
sdk: ^3.10.0
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: spotiflac_android
|
name: spotiflac_android
|
||||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||||
publish_to: "none"
|
publish_to: "none"
|
||||||
version: 2.1.7+45
|
version: 2.2.7+49
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.0
|
sdk: ^3.10.0
|
||||||
|
|||||||
Reference in New Issue
Block a user