mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-03 11:18:04 +02:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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:"
|
||||||
|
|||||||
+115
@@ -1,5 +1,120 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [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/cd205e22783a179aab80a2f0cc4445c84e59615a08c11d6e722ab4692c26ac37)
|
||||||
|
|
||||||
<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.
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 70 KiB |
+80
-48
@@ -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
|
||||||
@@ -52,46 +52,46 @@ type DoubleDoubleStatusResponse struct {
|
|||||||
func amazonArtistsMatch(expectedArtist, foundArtist string) bool {
|
func amazonArtistsMatch(expectedArtist, foundArtist string) bool {
|
||||||
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
|
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
|
||||||
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
|
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
|
||||||
|
|
||||||
// Exact match
|
// Exact match
|
||||||
if normExpected == normFound {
|
if normExpected == normFound {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if one contains the other
|
// Check if one contains the other
|
||||||
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
|
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check first artist (before comma or feat)
|
// Check first artist (before comma or feat)
|
||||||
expectedFirst := strings.Split(normExpected, ",")[0]
|
expectedFirst := strings.Split(normExpected, ",")[0]
|
||||||
expectedFirst = strings.Split(expectedFirst, " feat")[0]
|
expectedFirst = strings.Split(expectedFirst, " feat")[0]
|
||||||
expectedFirst = strings.Split(expectedFirst, " ft.")[0]
|
expectedFirst = strings.Split(expectedFirst, " ft.")[0]
|
||||||
expectedFirst = strings.TrimSpace(expectedFirst)
|
expectedFirst = strings.TrimSpace(expectedFirst)
|
||||||
|
|
||||||
foundFirst := strings.Split(normFound, ",")[0]
|
foundFirst := strings.Split(normFound, ",")[0]
|
||||||
foundFirst = strings.Split(foundFirst, " feat")[0]
|
foundFirst = strings.Split(foundFirst, " feat")[0]
|
||||||
foundFirst = strings.Split(foundFirst, " ft.")[0]
|
foundFirst = strings.Split(foundFirst, " ft.")[0]
|
||||||
foundFirst = strings.TrimSpace(foundFirst)
|
foundFirst = strings.TrimSpace(foundFirst)
|
||||||
|
|
||||||
if expectedFirst == foundFirst {
|
if expectedFirst == foundFirst {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if first artist is contained in the other
|
// Check if first artist is contained in the other
|
||||||
if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) {
|
if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean),
|
// If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean),
|
||||||
// assume they're the same artist with different transliteration
|
// assume they're the same artist with different transliteration
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,7 +124,7 @@ func (a *AmazonDownloader) waitForRateLimit() {
|
|||||||
defer amazonRateLimitMu.Unlock()
|
defer amazonRateLimitMu.Unlock()
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
// Reset counter every minute
|
// Reset counter every minute
|
||||||
if now.Sub(a.apiCallResetTime) >= time.Minute {
|
if now.Sub(a.apiCallResetTime) >= time.Minute {
|
||||||
a.apiCallCount = 0
|
a.apiCallCount = 0
|
||||||
@@ -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))
|
||||||
|
|
||||||
@@ -202,7 +201,7 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir
|
|||||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
fmt.Println("[Amazon] Submitting download request...")
|
fmt.Println("[Amazon] Submitting download request...")
|
||||||
|
|
||||||
// Retry logic for 429 errors (like PC version: 3 retries with 15s wait)
|
// Retry logic for 429 errors (like PC version: 3 retries with 15s wait)
|
||||||
var resp *http.Response
|
var resp *http.Response
|
||||||
maxRetries := 3
|
maxRetries := 3
|
||||||
@@ -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
|
||||||
@@ -450,12 +449,12 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
songlink := NewSongLinkClient()
|
songlink := NewSongLinkClient()
|
||||||
var availability *TrackAvailability
|
var availability *TrackAvailability
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
// Check if SpotifyID is actually a Deezer ID (format: "deezer:xxxxx")
|
// Check if SpotifyID is actually a Deezer ID (format: "deezer:xxxxx")
|
||||||
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
|
||||||
@@ -463,7 +462,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
} else {
|
} else {
|
||||||
return AmazonDownloadResult{}, fmt.Errorf("no valid Spotify or Deezer ID provided for Amazon lookup")
|
return AmazonDownloadResult{}, fmt.Errorf("no valid Spotify or Deezer ID provided for Amazon lookup")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return AmazonDownloadResult{}, fmt.Errorf("failed to check Amazon availability via SongLink: %w", err)
|
return AmazonDownloadResult{}, fmt.Errorf("failed to check Amazon availability via SongLink: %w", err)
|
||||||
}
|
}
|
||||||
@@ -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")
|
||||||
}
|
}
|
||||||
@@ -583,36 +601,50 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("[Amazon] ✓ Downloaded successfully from Amazon Music")
|
fmt.Println("[Amazon] ✓ Downloaded successfully from Amazon Music")
|
||||||
|
|
||||||
// Read actual quality from the downloaded FLAC file
|
// Read actual quality from the downloaded FLAC file
|
||||||
// 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,
|
// Read metadata from file AFTER embedding to get accurate values
|
||||||
BitDepth: 0,
|
// This ensures we return what's actually in the file
|
||||||
SampleRate: 0,
|
finalMeta, metaReadErr := ReadMetadata(outputPath)
|
||||||
}, nil
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
|
||||||
|
|
||||||
// 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
|
||||||
}
|
}
|
||||||
|
|||||||
+183
-105
@@ -17,17 +17,17 @@ func ParseSpotifyURL(url string) (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
result := map[string]string{
|
result := map[string]string{
|
||||||
"type": parsed.Type,
|
"type": parsed.Type,
|
||||||
"id": parsed.ID,
|
"id": parsed.ID,
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonBytes, err := json.Marshal(result)
|
jsonBytes, err := json.Marshal(result)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,18 +42,18 @@ func SetSpotifyAPICredentials(clientID, clientSecret string) {
|
|||||||
func GetSpotifyMetadata(spotifyURL string) (string, error) {
|
func GetSpotifyMetadata(spotifyURL string) (string, error) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
client := NewSpotifyMetadataClient()
|
client := NewSpotifyMetadataClient()
|
||||||
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
|
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonBytes, err := json.Marshal(data)
|
jsonBytes, err := json.Marshal(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,18 +62,18 @@ func GetSpotifyMetadata(spotifyURL string) (string, error) {
|
|||||||
func SearchSpotify(query string, limit int) (string, error) {
|
func SearchSpotify(query string, limit int) (string, error) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
client := NewSpotifyMetadataClient()
|
client := NewSpotifyMetadataClient()
|
||||||
results, err := client.SearchTracks(ctx, query, limit)
|
results, err := client.SearchTracks(ctx, query, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonBytes, err := json.Marshal(results)
|
jsonBytes, err := json.Marshal(results)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,18 +82,18 @@ func SearchSpotify(query string, limit int) (string, error) {
|
|||||||
func SearchSpotifyAll(query string, trackLimit, artistLimit int) (string, error) {
|
func SearchSpotifyAll(query string, trackLimit, artistLimit int) (string, error) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
client := NewSpotifyMetadataClient()
|
client := NewSpotifyMetadataClient()
|
||||||
results, err := client.SearchAll(ctx, query, trackLimit, artistLimit)
|
results, err := client.SearchAll(ctx, query, trackLimit, artistLimit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonBytes, err := json.Marshal(results)
|
jsonBytes, err := json.Marshal(results)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,12 +105,12 @@ func CheckAvailability(spotifyID, isrc string) (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonBytes, err := json.Marshal(availability)
|
jsonBytes, err := json.Marshal(availability)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -179,17 +181,17 @@ func DownloadTrack(requestJSON string) (string, error) {
|
|||||||
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
||||||
return errorResponse("Invalid request: " + err.Error())
|
return errorResponse("Invalid request: " + err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trim whitespace from string fields to prevent filename/path issues
|
// Trim whitespace from string fields to prevent filename/path issues
|
||||||
req.TrackName = strings.TrimSpace(req.TrackName)
|
req.TrackName = strings.TrimSpace(req.TrackName)
|
||||||
req.ArtistName = strings.TrimSpace(req.ArtistName)
|
req.ArtistName = strings.TrimSpace(req.ArtistName)
|
||||||
req.AlbumName = strings.TrimSpace(req.AlbumName)
|
req.AlbumName = strings.TrimSpace(req.AlbumName)
|
||||||
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
|
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
|
||||||
req.OutputDir = strings.TrimSpace(req.OutputDir)
|
req.OutputDir = strings.TrimSpace(req.OutputDir)
|
||||||
|
|
||||||
var result DownloadResult
|
var result DownloadResult
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
switch req.Service {
|
switch req.Service {
|
||||||
case "tidal":
|
case "tidal":
|
||||||
tidalResult, tidalErr := downloadFromTidal(req)
|
tidalResult, tidalErr := downloadFromTidal(req)
|
||||||
@@ -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,17 +240,18 @@ 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
|
||||||
default:
|
default:
|
||||||
return errorResponse("Unknown service: " + req.Service)
|
return errorResponse("Unknown service: " + req.Service)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errorResponse(err.Error())
|
return errorResponse(err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if file already exists
|
// Check if file already exists
|
||||||
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
|
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
|
||||||
actualPath := result.FilePath[7:]
|
actualPath := result.FilePath[7:]
|
||||||
@@ -264,21 +269,28 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read actual quality from downloaded file (more accurate than API)
|
// Read actual quality from downloaded file (more accurate than API)
|
||||||
quality, qErr := GetAudioQuality(result.FilePath)
|
quality, qErr := GetAudioQuality(result.FilePath)
|
||||||
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{
|
||||||
Success: true,
|
Success: true,
|
||||||
Message: "Download complete",
|
Message: "Download complete",
|
||||||
@@ -292,8 +304,9 @@ 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)
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
@@ -305,23 +318,23 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
||||||
return errorResponse("Invalid request: " + err.Error())
|
return errorResponse("Invalid request: " + err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trim whitespace from string fields to prevent filename/path issues
|
// Trim whitespace from string fields to prevent filename/path issues
|
||||||
req.TrackName = strings.TrimSpace(req.TrackName)
|
req.TrackName = strings.TrimSpace(req.TrackName)
|
||||||
req.ArtistName = strings.TrimSpace(req.ArtistName)
|
req.ArtistName = strings.TrimSpace(req.ArtistName)
|
||||||
req.AlbumName = strings.TrimSpace(req.AlbumName)
|
req.AlbumName = strings.TrimSpace(req.AlbumName)
|
||||||
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
|
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
|
||||||
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}
|
||||||
for _, s := range allServices {
|
for _, s := range allServices {
|
||||||
@@ -329,18 +342,18 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
services = append(services, s)
|
services = append(services, s)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
switch service {
|
switch service {
|
||||||
case "tidal":
|
case "tidal":
|
||||||
tidalResult, tidalErr := downloadFromTidal(req)
|
tidalResult, tidalErr := downloadFromTidal(req)
|
||||||
@@ -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,13 +406,14 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
// Check if file already exists
|
// Check if file already exists
|
||||||
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
|
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
|
||||||
@@ -410,21 +432,28 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read actual quality from downloaded file (more accurate than API)
|
// Read actual quality from downloaded file (more accurate than API)
|
||||||
quality, qErr := GetAudioQuality(result.FilePath)
|
quality, qErr := GetAudioQuality(result.FilePath)
|
||||||
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{
|
||||||
Success: true,
|
Success: true,
|
||||||
Message: "Downloaded from " + service,
|
Message: "Downloaded from " + service,
|
||||||
@@ -432,14 +461,21 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
lastErr = err
|
lastErr = err
|
||||||
}
|
}
|
||||||
|
|
||||||
return errorResponse("All services failed. Last error: " + lastErr.Error())
|
return errorResponse("All services failed. Last error: " + lastErr.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -477,6 +513,44 @@ 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)
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
@@ -485,17 +559,17 @@ func SetDownloadDirectory(path string) error {
|
|||||||
// CheckDuplicate checks if a file with the given ISRC exists
|
// CheckDuplicate checks if a file with the given ISRC exists
|
||||||
func CheckDuplicate(outputDir, isrc string) (string, error) {
|
func CheckDuplicate(outputDir, isrc string) (string, error) {
|
||||||
existingFile, exists := CheckISRCExists(outputDir, isrc)
|
existingFile, exists := CheckISRCExists(outputDir, isrc)
|
||||||
|
|
||||||
result := map[string]interface{}{
|
result := map[string]interface{}{
|
||||||
"exists": exists,
|
"exists": exists,
|
||||||
"filepath": existingFile,
|
"filepath": existingFile,
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonBytes, err := json.Marshal(result)
|
jsonBytes, err := json.Marshal(result)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -525,7 +599,7 @@ func BuildFilename(template string, metadataJSON string) (string, error) {
|
|||||||
if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
|
if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
filename := buildFilenameFromTemplate(template, metadata)
|
filename := buildFilenameFromTemplate(template, metadata)
|
||||||
return filename, nil
|
return filename, nil
|
||||||
}
|
}
|
||||||
@@ -655,18 +729,18 @@ func ClearTrackIDCache() {
|
|||||||
func SearchDeezerAll(query string, trackLimit, artistLimit int) (string, error) {
|
func SearchDeezerAll(query string, trackLimit, artistLimit int) (string, error) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
client := GetDeezerClient()
|
client := GetDeezerClient()
|
||||||
results, err := client.SearchAll(ctx, query, trackLimit, artistLimit)
|
results, err := client.SearchAll(ctx, query, trackLimit, artistLimit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonBytes, err := json.Marshal(results)
|
jsonBytes, err := json.Marshal(results)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -676,11 +750,11 @@ func SearchDeezerAll(query string, trackLimit, artistLimit int) (string, error)
|
|||||||
func GetDeezerMetadata(resourceType, resourceID string) (string, error) {
|
func GetDeezerMetadata(resourceType, resourceID string) (string, error) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
client := GetDeezerClient()
|
client := GetDeezerClient()
|
||||||
var data interface{}
|
var data interface{}
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
switch resourceType {
|
switch resourceType {
|
||||||
case "track":
|
case "track":
|
||||||
data, err = client.GetTrack(ctx, resourceID)
|
data, err = client.GetTrack(ctx, resourceID)
|
||||||
@@ -693,16 +767,16 @@ func GetDeezerMetadata(resourceType, resourceID string) (string, error) {
|
|||||||
default:
|
default:
|
||||||
return "", fmt.Errorf("unsupported Deezer resource type: %s", resourceType)
|
return "", fmt.Errorf("unsupported Deezer resource type: %s", resourceType)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonBytes, err := json.Marshal(data)
|
jsonBytes, err := json.Marshal(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -712,17 +786,17 @@ func ParseDeezerURLExport(url string) (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
result := map[string]string{
|
result := map[string]string{
|
||||||
"type": resourceType,
|
"type": resourceType,
|
||||||
"id": resourceID,
|
"id": resourceID,
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonBytes, err := json.Marshal(result)
|
jsonBytes, err := json.Marshal(result)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -730,18 +804,18 @@ func ParseDeezerURLExport(url string) (string, error) {
|
|||||||
func SearchDeezerByISRC(isrc string) (string, error) {
|
func SearchDeezerByISRC(isrc string) (string, error) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
client := GetDeezerClient()
|
client := GetDeezerClient()
|
||||||
track, err := client.SearchByISRC(ctx, isrc)
|
track, err := client.SearchByISRC(ctx, isrc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonBytes, err := json.Marshal(track)
|
jsonBytes, err := json.Marshal(track)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -751,52 +825,52 @@ func SearchDeezerByISRC(isrc string) (string, error) {
|
|||||||
func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
|
func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
songlink := NewSongLinkClient()
|
songlink := NewSongLinkClient()
|
||||||
deezerClient := GetDeezerClient()
|
deezerClient := GetDeezerClient()
|
||||||
|
|
||||||
// For tracks, we can use SongLink to get Deezer ID
|
// For tracks, we can use SongLink to get Deezer ID
|
||||||
if resourceType == "track" {
|
if resourceType == "track" {
|
||||||
deezerID, err := songlink.GetDeezerIDFromSpotify(spotifyID)
|
deezerID, err := songlink.GetDeezerIDFromSpotify(spotifyID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("could not find Deezer equivalent: %w", err)
|
return "", fmt.Errorf("could not find Deezer equivalent: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch metadata from Deezer
|
// Fetch metadata from Deezer
|
||||||
trackResp, err := deezerClient.GetTrack(ctx, deezerID)
|
trackResp, err := deezerClient.GetTrack(ctx, deezerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to fetch Deezer metadata: %w", err)
|
return "", fmt.Errorf("failed to fetch Deezer metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonBytes, err := json.Marshal(trackResp)
|
jsonBytes, err := json.Marshal(trackResp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// For albums, SongLink also provides mapping
|
// For albums, SongLink also provides mapping
|
||||||
if resourceType == "album" {
|
if resourceType == "album" {
|
||||||
deezerID, err := songlink.GetDeezerAlbumIDFromSpotify(spotifyID)
|
deezerID, err := songlink.GetDeezerAlbumIDFromSpotify(spotifyID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("could not find Deezer album: %w", err)
|
return "", fmt.Errorf("could not find Deezer album: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch album metadata from Deezer
|
// Fetch album metadata from Deezer
|
||||||
albumResp, err := deezerClient.GetAlbum(ctx, deezerID)
|
albumResp, err := deezerClient.GetAlbum(ctx, deezerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to fetch Deezer album metadata: %w", err)
|
return "", fmt.Errorf("failed to fetch Deezer album metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonBytes, err := json.Marshal(albumResp)
|
jsonBytes, err := json.Marshal(albumResp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// For artists/playlists, SongLink doesn't provide direct mapping
|
// For artists/playlists, SongLink doesn't provide direct mapping
|
||||||
return "", fmt.Errorf("Spotify to Deezer conversion only supported for tracks and albums. Please search by name for %s", resourceType)
|
return "", fmt.Errorf("Spotify to Deezer conversion only supported for tracks and albums. Please search by name for %s", resourceType)
|
||||||
}
|
}
|
||||||
@@ -805,7 +879,7 @@ func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
|
|||||||
func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
|
func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
// Try Spotify first
|
// Try Spotify first
|
||||||
client := NewSpotifyMetadataClient()
|
client := NewSpotifyMetadataClient()
|
||||||
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
|
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
|
||||||
@@ -816,32 +890,32 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
|
|||||||
}
|
}
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if it's a rate limit error
|
// Check if it's a rate limit error
|
||||||
errStr := strings.ToLower(err.Error())
|
errStr := strings.ToLower(err.Error())
|
||||||
if !strings.Contains(errStr, "429") && !strings.Contains(errStr, "rate") && !strings.Contains(errStr, "limit") {
|
if !strings.Contains(errStr, "429") && !strings.Contains(errStr, "rate") && !strings.Contains(errStr, "limit") {
|
||||||
// Not a rate limit error, return original error
|
// Not a rate limit error, return original error
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rate limited - try Deezer fallback for tracks and albums
|
// Rate limited - try Deezer fallback for tracks and albums
|
||||||
parsed, parseErr := parseSpotifyURI(spotifyURL)
|
parsed, parseErr := parseSpotifyURI(spotifyURL)
|
||||||
if parseErr != nil {
|
if parseErr != nil {
|
||||||
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
|
||||||
return ConvertSpotifyToDeezer(parsed.Type, parsed.ID)
|
return ConvertSpotifyToDeezer(parsed.Type, parsed.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Artist and playlist not supported for fallback
|
// Artist and playlist not supported for fallback
|
||||||
if parsed.Type == "artist" {
|
if parsed.Type == "artist" {
|
||||||
return "", fmt.Errorf("spotify rate limited. Artist pages require Spotify API - please try again later")
|
return "", fmt.Errorf("spotify rate limited. Artist pages require Spotify API - please try again later")
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", fmt.Errorf("spotify rate limited. Playlists are user-specific and require Spotify API")
|
return "", fmt.Errorf("spotify rate limited. Playlists are user-specific and require Spotify API")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -855,12 +929,12 @@ func CheckAvailabilityFromDeezerID(deezerTrackID string) (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonBytes, err := json.Marshal(availability)
|
jsonBytes, err := json.Marshal(availability)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -874,12 +948,12 @@ func CheckAvailabilityByPlatformID(platform, entityType, entityID string) (strin
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonBytes, err := json.Marshal(availability)
|
jsonBytes, err := json.Marshal(availability)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -905,24 +979,28 @@ func errorResponse(msg string) (string, error) {
|
|||||||
// Determine error type based on message
|
// Determine error type based on message
|
||||||
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"
|
||||||
}
|
}
|
||||||
|
|
||||||
resp := DownloadResponse{
|
resp := DownloadResponse{
|
||||||
Success: false,
|
Success: false,
|
||||||
Error: msg,
|
Error: msg,
|
||||||
|
|||||||
+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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
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)
|
||||||
|
}
|
||||||
+26
-2
@@ -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 ""
|
||||||
|
|||||||
+285
-57
@@ -52,49 +52,224 @@ type QobuzTrack struct {
|
|||||||
func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
|
func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
|
||||||
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
|
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
|
||||||
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
|
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
|
||||||
|
|
||||||
// Exact match
|
// Exact match
|
||||||
if normExpected == normFound {
|
if normExpected == normFound {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if one contains the other
|
// Check if one contains the other
|
||||||
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
|
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check first artist (before comma or feat)
|
// Check first artist (before comma or feat)
|
||||||
expectedFirst := strings.Split(normExpected, ",")[0]
|
expectedFirst := strings.Split(normExpected, ",")[0]
|
||||||
expectedFirst = strings.Split(expectedFirst, " feat")[0]
|
expectedFirst = strings.Split(expectedFirst, " feat")[0]
|
||||||
expectedFirst = strings.Split(expectedFirst, " ft.")[0]
|
expectedFirst = strings.Split(expectedFirst, " ft.")[0]
|
||||||
expectedFirst = strings.TrimSpace(expectedFirst)
|
expectedFirst = strings.TrimSpace(expectedFirst)
|
||||||
|
|
||||||
foundFirst := strings.Split(normFound, ",")[0]
|
foundFirst := strings.Split(normFound, ",")[0]
|
||||||
foundFirst = strings.Split(foundFirst, " feat")[0]
|
foundFirst = strings.Split(foundFirst, " feat")[0]
|
||||||
foundFirst = strings.Split(foundFirst, " ft.")[0]
|
foundFirst = strings.Split(foundFirst, " ft.")[0]
|
||||||
foundFirst = strings.TrimSpace(foundFirst)
|
foundFirst = strings.TrimSpace(foundFirst)
|
||||||
|
|
||||||
if expectedFirst == foundFirst {
|
if expectedFirst == foundFirst {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if first artist is contained in the other
|
// Check if first artist is contained in the other
|
||||||
if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) {
|
if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) {
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|||||||
+340
-123
@@ -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()
|
||||||
@@ -349,7 +348,7 @@ func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) {
|
|||||||
// normalizeTitle normalizes a track title for comparison (kept for potential future use)
|
// normalizeTitle normalizes a track title for comparison (kept for potential future use)
|
||||||
func normalizeTitle(title string) string {
|
func normalizeTitle(title string) string {
|
||||||
normalized := strings.ToLower(strings.TrimSpace(title))
|
normalized := strings.ToLower(strings.TrimSpace(title))
|
||||||
|
|
||||||
// Remove common suffixes in parentheses or brackets
|
// Remove common suffixes in parentheses or brackets
|
||||||
suffixPatterns := []string{
|
suffixPatterns := []string{
|
||||||
" (remaster)", " (remastered)", " (deluxe)", " (deluxe edition)",
|
" (remaster)", " (remastered)", " (deluxe)", " (deluxe edition)",
|
||||||
@@ -359,12 +358,12 @@ func normalizeTitle(title string) string {
|
|||||||
for _, suffix := range suffixPatterns {
|
for _, suffix := range suffixPatterns {
|
||||||
normalized = strings.TrimSuffix(normalized, suffix)
|
normalized = strings.TrimSuffix(normalized, suffix)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove multiple spaces
|
// Remove multiple spaces
|
||||||
for strings.Contains(normalized, " ") {
|
for strings.Contains(normalized, " ") {
|
||||||
normalized = strings.ReplaceAll(normalized, " ", " ")
|
normalized = strings.ReplaceAll(normalized, " ", " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalized
|
return normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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]
|
||||||
@@ -496,7 +522,7 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
|||||||
isrcMatches = append(isrcMatches, track)
|
isrcMatches = append(isrcMatches, track)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(isrcMatches) > 0 {
|
if len(isrcMatches) > 0 {
|
||||||
// Verify duration first (most important check)
|
// Verify duration first (most important check)
|
||||||
if expectedDuration > 0 {
|
if expectedDuration > 0 {
|
||||||
@@ -511,28 +537,28 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
|||||||
durationVerifiedMatches = append(durationVerifiedMatches, track)
|
durationVerifiedMatches = append(durationVerifiedMatches, track)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
@@ -626,7 +651,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 +661,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,32 +669,32 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log response preview
|
// Log response preview
|
||||||
bodyPreview := string(body)
|
bodyPreview := string(body)
|
||||||
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,
|
||||||
@@ -725,13 +750,13 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
|||||||
}
|
}
|
||||||
|
|
||||||
manifestStr := string(manifestBytes)
|
manifestStr := string(manifestBytes)
|
||||||
|
|
||||||
// Debug: log first 500 chars of manifest for debugging
|
// Debug: log first 500 chars of manifest for debugging
|
||||||
manifestPreview := manifestStr
|
manifestPreview := manifestStr
|
||||||
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 +806,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 +819,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 +842,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 +930,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 +942,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 +1007,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 +1039,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,27 +1047,27 @@ 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
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
progress := float64(i+1) / float64(totalSegments)
|
progress := float64(i+1) / float64(totalSegments)
|
||||||
SetItemProgress(itemID, progress, 0, 0)
|
SetItemProgress(itemID, progress, 0, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := client.Get(mediaURL)
|
resp, err := client.Get(mediaURL)
|
||||||
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 +1075,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,56 +1101,230 @@ 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
|
||||||
func artistsMatch(spotifyArtist, tidalArtist string) bool {
|
func artistsMatch(spotifyArtist, tidalArtist string) bool {
|
||||||
normSpotify := strings.ToLower(strings.TrimSpace(spotifyArtist))
|
normSpotify := strings.ToLower(strings.TrimSpace(spotifyArtist))
|
||||||
normTidal := strings.ToLower(strings.TrimSpace(tidalArtist))
|
normTidal := strings.ToLower(strings.TrimSpace(tidalArtist))
|
||||||
|
|
||||||
// Exact match
|
// Exact match
|
||||||
if normSpotify == normTidal {
|
if normSpotify == normTidal {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if one contains the other (for cases like "Artist" vs "Artist feat. Someone")
|
// Check if one contains the other (for cases like "Artist" vs "Artist feat. Someone")
|
||||||
if strings.Contains(normSpotify, normTidal) || strings.Contains(normTidal, normSpotify) {
|
if strings.Contains(normSpotify, normTidal) || strings.Contains(normTidal, normSpotify) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check first artist (before comma or feat)
|
// Check first artist (before comma or feat)
|
||||||
spotifyFirst := strings.Split(normSpotify, ",")[0]
|
spotifyFirst := strings.Split(normSpotify, ",")[0]
|
||||||
spotifyFirst = strings.Split(spotifyFirst, " feat")[0]
|
spotifyFirst = strings.Split(spotifyFirst, " feat")[0]
|
||||||
spotifyFirst = strings.Split(spotifyFirst, " ft.")[0]
|
spotifyFirst = strings.Split(spotifyFirst, " ft.")[0]
|
||||||
spotifyFirst = strings.TrimSpace(spotifyFirst)
|
spotifyFirst = strings.TrimSpace(spotifyFirst)
|
||||||
|
|
||||||
tidalFirst := strings.Split(normTidal, ",")[0]
|
tidalFirst := strings.Split(normTidal, ",")[0]
|
||||||
tidalFirst = strings.Split(tidalFirst, " feat")[0]
|
tidalFirst = strings.Split(tidalFirst, " feat")[0]
|
||||||
tidalFirst = strings.Split(tidalFirst, " ft.")[0]
|
tidalFirst = strings.Split(tidalFirst, " ft.")[0]
|
||||||
tidalFirst = strings.TrimSpace(tidalFirst)
|
tidalFirst = strings.TrimSpace(tidalFirst)
|
||||||
|
|
||||||
if spotifyFirst == tidalFirst {
|
if spotifyFirst == tidalFirst {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if first artist is contained in the other
|
// Check if first artist is contained in the other
|
||||||
if strings.Contains(spotifyFirst, tidalFirst) || strings.Contains(tidalFirst, spotifyFirst) {
|
if strings.Contains(spotifyFirst, tidalFirst) || strings.Contains(tidalFirst, spotifyFirst) {
|
||||||
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 +1353,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 +1378,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 +1387,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)
|
||||||
@@ -1206,14 +1416,14 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify duration if we have expected duration
|
// Verify duration if we have expected duration
|
||||||
if track != nil && expectedDurationSec > 0 {
|
if track != nil && expectedDurationSec > 0 {
|
||||||
durationDiff := track.Duration - expectedDurationSec
|
durationDiff := track.Duration - expectedDurationSec
|
||||||
@@ -1222,7 +1432,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 +1444,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 +1456,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 +1487,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 != "" {
|
||||||
@@ -1298,11 +1514,11 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
if fileInfo, statErr := os.Stat(m4aPath); statErr == nil && fileInfo.Size() > 0 {
|
if fileInfo, statErr := os.Stat(m4aPath); statErr == nil && fileInfo.Size() > 0 {
|
||||||
return TidalDownloadResult{FilePath: "EXISTS:" + m4aPath}, nil
|
return TidalDownloadResult{FilePath: "EXISTS:" + m4aPath}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 +1527,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 +1536,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,16 +1554,16 @@ 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)"
|
||||||
}
|
}
|
||||||
return "Direct URL"
|
return "Direct URL"
|
||||||
}())
|
}())
|
||||||
|
|
||||||
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 +1584,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 +1597,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 +1618,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,21 +1629,21 @@ 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 != "" {
|
||||||
// metadata.Lyrics = parallelResult.LyricsLRC
|
// metadata.Lyrics = parallelResult.LyricsLRC
|
||||||
// }
|
// }
|
||||||
|
|
||||||
// SKIP metadata embedding for M4A to prevent issues with FFmpeg conversion
|
// SKIP metadata embedding for M4A to prevent issues with FFmpeg conversion
|
||||||
// M4A files from DASH are often fragmented and editing metadata might corrupt the container
|
// M4A files from DASH are often fragmented and editing metadata might corrupt the container
|
||||||
// structure that FFmpeg expects. Metadata will be re-embedded after conversion to FLAC in Flutter.
|
// structure that FFmpeg expects. Metadata will be re-embedded after conversion to FLAC in Flutter.
|
||||||
|
|
||||||
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 +1662,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.5';
|
||||||
static const String buildNumber = '45';
|
static const String buildNumber = '47';
|
||||||
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,6 +78,8 @@ 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) => DownloadHistoryItem(
|
||||||
@@ -91,6 +99,8 @@ class DownloadHistoryItem {
|
|||||||
duration: json['duration'] as int?,
|
duration: json['duration'] as int?,
|
||||||
releaseDate: json['releaseDate'] as String?,
|
releaseDate: json['releaseDate'] as String?,
|
||||||
quality: json['quality'] as String?,
|
quality: json['quality'] as String?,
|
||||||
|
bitDepth: json['bitDepth'] as int?,
|
||||||
|
sampleRate: json['sampleRate'] as int?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -760,18 +770,19 @@ 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();
|
||||||
@@ -821,6 +832,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
if (track.isrc != null) {
|
if (track.isrc != null) {
|
||||||
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
|
||||||
@@ -833,7 +846,7 @@ 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)');
|
||||||
@@ -1094,40 +1107,55 @@ 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('Enriching incomplete metadata for Deezer track: ${trackToDownload.name}');
|
||||||
_log.d('Enriching incomplete metadata for Deezer track: ${trackToDownload.name}');
|
_log.d('Current ISRC: ${trackToDownload.isrc}, TrackNumber: ${trackToDownload.trackNumber}');
|
||||||
final rawId = trackToDownload.id.split(':')[1];
|
final rawId = trackToDownload.id.split(':')[1];
|
||||||
final fullData = await PlatformBridge.getDeezerMetadata('track', rawId);
|
_log.d('Fetching full metadata for Deezer ID: $rawId');
|
||||||
|
final fullData = await PlatformBridge.getDeezerMetadata('track', rawId);
|
||||||
if (fullData.containsKey('track')) {
|
_log.d('Got response keys: ${fullData.keys.toList()}');
|
||||||
final fullTrack = Track.fromJson(fullData['track'] as Map<String, dynamic>);
|
|
||||||
// Merge with existing (keep override quality/service if any, but update metadata)
|
if (fullData.containsKey('track')) {
|
||||||
|
// Parse Go backend response (snake_case) to Track
|
||||||
|
final trackData = fullData['track'];
|
||||||
|
_log.d('Track data type: ${trackData.runtimeType}');
|
||||||
|
if (trackData is Map<String, dynamic>) {
|
||||||
|
final data = trackData;
|
||||||
|
_log.d('Track data keys: ${data.keys.toList()}');
|
||||||
|
_log.d('ISRC from API: ${data['isrc']}');
|
||||||
trackToDownload = Track(
|
trackToDownload = Track(
|
||||||
id: fullTrack.id.isNotEmpty ? fullTrack.id : trackToDownload.id,
|
id: (data['spotify_id'] as String?) ?? trackToDownload.id,
|
||||||
name: fullTrack.name,
|
name: (data['name'] as String?) ?? trackToDownload.name,
|
||||||
artistName: fullTrack.artistName,
|
artistName: (data['artists'] as String?) ?? trackToDownload.artistName,
|
||||||
albumName: fullTrack.albumName,
|
albumName: (data['album_name'] as String?) ?? trackToDownload.albumName,
|
||||||
albumArtist: fullTrack.albumArtist,
|
albumArtist: data['album_artist'] as String?,
|
||||||
coverUrl: fullTrack.coverUrl,
|
coverUrl: data['images'] as String?,
|
||||||
duration: fullTrack.duration,
|
// duration_ms from Go is in milliseconds, Track.duration is in seconds
|
||||||
isrc: fullTrack.isrc ?? trackToDownload.isrc,
|
duration: ((data['duration_ms'] as int?) ?? (trackToDownload.duration * 1000)) ~/ 1000,
|
||||||
trackNumber: fullTrack.trackNumber,
|
isrc: (data['isrc'] as String?) ?? trackToDownload.isrc,
|
||||||
discNumber: fullTrack.discNumber,
|
trackNumber: data['track_number'] as int?,
|
||||||
releaseDate: fullTrack.releaseDate,
|
discNumber: data['disc_number'] as int?,
|
||||||
deezerId: fullTrack.deezerId,
|
releaseDate: data['release_date'] as String?,
|
||||||
|
deezerId: rawId,
|
||||||
availability: trackToDownload.availability,
|
availability: trackToDownload.availability,
|
||||||
);
|
);
|
||||||
_log.d('Metadata enriched: Track ${trackToDownload.trackNumber}, Disc ${trackToDownload.discNumber}, Year ${trackToDownload.releaseDate}');
|
_log.d('Metadata enriched: Track ${trackToDownload.trackNumber}, Disc ${trackToDownload.discNumber}, ISRC ${trackToDownload.isrc}');
|
||||||
|
} else {
|
||||||
// Update item in state with enriched track
|
_log.w('Unexpected track data type: ${trackData.runtimeType}');
|
||||||
// This is important so the UI (and history) reflects the enriched data
|
}
|
||||||
// We don't perform a full `updateItemStatus` here to avoid UI flicker, just local var
|
} 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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1223,11 +1251,11 @@ 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 {
|
||||||
@@ -1238,7 +1266,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
_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(item.id, DownloadStatus.downloading, progress: 0.95);
|
||||||
final flacPath = await FFmpegService.convertM4aToFlac(filePath!);
|
final flacPath = await FFmpegService.convertM4aToFlac(filePath);
|
||||||
|
|
||||||
if (flacPath != null) {
|
if (flacPath != null) {
|
||||||
filePath = flacPath;
|
filePath = flacPath;
|
||||||
@@ -1257,7 +1285,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
final backendYear = result['release_date'] as String?;
|
final backendYear = result['release_date'] as String?;
|
||||||
final backendAlbum = result['album'] as String?;
|
final backendAlbum = result['album'] as String?;
|
||||||
|
|
||||||
// Create updated track object
|
_log.d('Backend metadata - Track: $backendTrackNum, Disc: $backendDiscNum, Year: $backendYear');
|
||||||
|
|
||||||
|
// Create updated track object with safety check for 0/null
|
||||||
|
final newTrackNumber = (backendTrackNum != null && backendTrackNum > 0) ? backendTrackNum : trackToDownload.trackNumber;
|
||||||
|
final newDiscNumber = (backendDiscNum != null && backendDiscNum > 0) ? backendDiscNum : trackToDownload.discNumber;
|
||||||
|
|
||||||
|
_log.d('Final metadata for embedding - Track: $newTrackNumber, Disc: $newDiscNumber');
|
||||||
|
|
||||||
finalTrack = Track(
|
finalTrack = Track(
|
||||||
id: trackToDownload.id,
|
id: trackToDownload.id,
|
||||||
name: trackToDownload.name,
|
name: trackToDownload.name,
|
||||||
@@ -1267,8 +1302,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
coverUrl: trackToDownload.coverUrl,
|
coverUrl: trackToDownload.coverUrl,
|
||||||
duration: trackToDownload.duration,
|
duration: trackToDownload.duration,
|
||||||
isrc: trackToDownload.isrc,
|
isrc: trackToDownload.isrc,
|
||||||
trackNumber: (backendTrackNum != null && backendTrackNum > 0) ? backendTrackNum : trackToDownload.trackNumber,
|
trackNumber: newTrackNumber,
|
||||||
discNumber: (backendDiscNum != null && backendDiscNum > 0) ? backendDiscNum : trackToDownload.discNumber,
|
discNumber: newDiscNumber,
|
||||||
releaseDate: backendYear ?? trackToDownload.releaseDate,
|
releaseDate: backendYear ?? trackToDownload.releaseDate,
|
||||||
deezerId: trackToDownload.deezerId,
|
deezerId: trackToDownload.deezerId,
|
||||||
availability: trackToDownload.availability,
|
availability: trackToDownload.availability,
|
||||||
@@ -1337,6 +1372,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
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(
|
ref.read(downloadHistoryProvider.notifier).addToHistory(
|
||||||
DownloadHistoryItem(
|
DownloadHistoryItem(
|
||||||
@@ -1350,13 +1388,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
service: result['service'] as String? ?? item.service,
|
service: result['service'] as String? ?? item.service,
|
||||||
downloadedAt: DateTime.now(),
|
downloadedAt: DateTime.now(),
|
||||||
// Additional metadata
|
// Additional metadata
|
||||||
isrc: item.track.isrc,
|
isrc: (backendISRC != null && backendISRC.isNotEmpty) ? backendISRC : item.track.isrc,
|
||||||
spotifyId: item.track.id,
|
spotifyId: item.track.id,
|
||||||
trackNumber: (backendTrackNum != null && backendTrackNum > 0) ? backendTrackNum : item.track.trackNumber,
|
trackNumber: (backendTrackNum != null && backendTrackNum > 0) ? backendTrackNum : item.track.trackNumber,
|
||||||
discNumber: (backendDiscNum != null && backendDiscNum > 0) ? backendDiscNum : item.track.discNumber,
|
discNumber: (backendDiscNum != null && backendDiscNum > 0) ? backendDiscNum : item.track.discNumber,
|
||||||
duration: item.track.duration,
|
duration: item.track.duration,
|
||||||
releaseDate: (backendYear != null && backendYear.isNotEmpty) ? backendYear : item.track.releaseDate,
|
releaseDate: (backendYear != null && backendYear.isNotEmpty) ? backendYear : item.track.releaseDate,
|
||||||
quality: actualQuality,
|
quality: actualQuality,
|
||||||
|
bitDepth: backendBitDepth,
|
||||||
|
sampleRate: backendSampleRate,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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>(
|
||||||
|
|||||||
@@ -230,8 +230,34 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
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();
|
// Parse tracks with error handling per item
|
||||||
final artists = artistList.map((a) => _parseSearchArtist(a as Map<String, dynamic>)).toList();
|
final tracks = <Track>[];
|
||||||
|
for (final t in trackList) {
|
||||||
|
try {
|
||||||
|
if (t is Map<String, dynamic>) {
|
||||||
|
tracks.add(_parseSearchTrack(t));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[Search] Failed to parse track: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse artists with error handling per item
|
||||||
|
final artists = <SearchArtist>[];
|
||||||
|
for (final a in artistList) {
|
||||||
|
try {
|
||||||
|
if (a is Map<String, dynamic>) {
|
||||||
|
artists.add(_parseSearchArtist(a));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[Search] Failed to parse artist: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[Search] Parsed ${tracks.length} tracks, ${artists.length} artists');
|
||||||
|
|
||||||
state = TrackState(
|
state = TrackState(
|
||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
@@ -310,18 +336,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)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -651,6 +651,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 +671,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),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -168,6 +168,25 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Debug section
|
||||||
|
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Debug')),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: SettingsGroup(
|
||||||
|
children: [
|
||||||
|
SettingsSwitchItem(
|
||||||
|
icon: Icons.bug_report,
|
||||||
|
title: 'Detailed Logging',
|
||||||
|
subtitle: settings.enableLogging
|
||||||
|
? 'Detailed logs are being recorded'
|
||||||
|
: 'Enable for bug reports',
|
||||||
|
value: settings.enableLogging,
|
||||||
|
onChanged: (v) => ref.read(settingsProvider.notifier).setEnableLogging(v),
|
||||||
|
showDivider: false,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -67,10 +68,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,
|
||||||
),
|
),
|
||||||
@@ -401,28 +414,33 @@ 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! > 1)
|
||||||
_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)
|
if (item.spotifyId != null && item.spotifyId!.isNotEmpty)
|
||||||
_MetadataItem('Spotify ID', item.spotifyId!),
|
_MetadataItem('Spotify ID', item.spotifyId!),
|
||||||
if (item.quality != null && item.quality!.isNotEmpty)
|
|
||||||
_MetadataItem('Quality', _formatQuality(item.quality!)),
|
|
||||||
_MetadataItem('Service', item.service.toUpperCase()),
|
_MetadataItem('Service', item.service.toUpperCase()),
|
||||||
_MetadataItem('Downloaded', _formatFullDate(item.downloadedAt)),
|
_MetadataItem('Downloaded', _formatFullDate(item.downloadedAt)),
|
||||||
];
|
];
|
||||||
@@ -476,32 +494,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 +562,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 +570,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 +883,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 +900,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 +910,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)),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+269
-9
@@ -1,7 +1,233 @@
|
|||||||
|
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)
|
||||||
|
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 +241,48 @@ 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
|
||||||
class AppLogger {
|
class AppLogger {
|
||||||
final String _tag;
|
final String _tag;
|
||||||
|
late final Logger _logger;
|
||||||
AppLogger(this._tag);
|
|
||||||
|
AppLogger(this._tag) {
|
||||||
void d(String message) => log.d('[$_tag] $message');
|
_logger = Logger(
|
||||||
void i(String message) => log.i('[$_tag] $message');
|
printer: SimplePrinter(printTime: false, colors: false),
|
||||||
void w(String message) => log.w('[$_tag] $message');
|
output: BufferedOutput(_tag),
|
||||||
void e(String message, [Object? error, StackTrace? stackTrace]) =>
|
level: Level.debug,
|
||||||
log.e('[$_tag] $message', error: error, stackTrace: stackTrace);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void d(String message) {
|
||||||
|
_logger.d(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
void i(String message) {
|
||||||
|
_logger.i(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
void w(String message) {
|
||||||
|
_logger.w(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
void e(String message, [Object? error, StackTrace? stackTrace]) {
|
||||||
|
if (error != null) {
|
||||||
|
LogBuffer().add(LogEntry(
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
level: 'ERROR',
|
||||||
|
tag: _tag,
|
||||||
|
message: message,
|
||||||
|
error: error.toString(),
|
||||||
|
));
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint('[$_tag] ERROR: $message | $error');
|
||||||
|
if (stackTrace != null) {
|
||||||
|
debugPrint(stackTrace.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_logger.e(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.5+47
|
||||||
|
|
||||||
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.5+47
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.0
|
sdk: ^3.10.0
|
||||||
|
|||||||
Reference in New Issue
Block a user