mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-05 04:08:02 +02:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bea5dd1d4a | |||
| 8726a0858a |
@@ -382,20 +382,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:"
|
||||||
|
|||||||
@@ -1,5 +1,63 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [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/cb87d23fc9fd4a6f0a7b2b934773029c7e7ab25101dfce503e68e9c9e8921fca/)
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
|
|||||||
@@ -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") ?: ""
|
||||||
|
|||||||
+51
-19
@@ -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
|
||||||
@@ -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
|
||||||
@@ -182,7 +181,7 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir
|
|||||||
|
|
||||||
// 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))
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -546,16 +545,35 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
fmt.Printf("[Amazon] DoubleDouble returned: %s - %s\n", artistName, trackName)
|
fmt.Printf("[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
|
||||||
|
fmt.Printf("[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
|
||||||
|
fmt.Printf("[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,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -589,30 +607,44 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
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)
|
fmt.Printf("[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)
|
fmt.Printf("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||||
// Return 0 to indicate unknown quality
|
|
||||||
return AmazonDownloadResult{
|
|
||||||
FilePath: outputPath,
|
|
||||||
BitDepth: 0,
|
|
||||||
SampleRate: 0,
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
// Read metadata from file AFTER embedding to get accurate values
|
||||||
|
// This ensures we return what's actually in the file
|
||||||
|
finalMeta, metaReadErr := ReadMetadata(outputPath)
|
||||||
|
if metaReadErr == nil && finalMeta != nil {
|
||||||
|
fmt.Printf("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n",
|
||||||
|
finalMeta.TrackNumber, finalMeta.DiscNumber, finalMeta.Date)
|
||||||
|
actualTrackNum = finalMeta.TrackNumber
|
||||||
|
actualDiscNum = finalMeta.DiscNumber
|
||||||
|
if finalMeta.Date != "" {
|
||||||
|
// Use date from file if available
|
||||||
|
req.ReleaseDate = finalMeta.Date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Add to ISRC index for fast duplicate checking
|
// Add to ISRC index for fast duplicate checking
|
||||||
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
|
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
|
||||||
|
|
||||||
|
bitDepth := 0
|
||||||
|
sampleRate := 0
|
||||||
|
if err == nil {
|
||||||
|
bitDepth = quality.BitDepth
|
||||||
|
sampleRate = quality.SampleRate
|
||||||
|
}
|
||||||
|
|
||||||
return AmazonDownloadResult{
|
return AmazonDownloadResult{
|
||||||
FilePath: outputPath,
|
FilePath: outputPath,
|
||||||
BitDepth: quality.BitDepth,
|
BitDepth: bitDepth,
|
||||||
SampleRate: quality.SampleRate,
|
SampleRate: sampleRate,
|
||||||
Title: req.TrackName,
|
Title: req.TrackName,
|
||||||
Artist: req.ArtistName,
|
Artist: req.ArtistName,
|
||||||
Album: req.AlbumName,
|
Album: req.AlbumName,
|
||||||
ReleaseDate: req.ReleaseDate,
|
ReleaseDate: req.ReleaseDate,
|
||||||
TrackNumber: req.TrackNumber,
|
TrackNumber: actualTrackNum,
|
||||||
DiscNumber: req.DiscNumber,
|
DiscNumber: actualDiscNum,
|
||||||
|
ISRC: req.ISRC,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+88
-14
@@ -133,7 +133,7 @@ type DownloadRequest struct {
|
|||||||
DiscNumber int `json:"disc_number"`
|
DiscNumber int `json:"disc_number"`
|
||||||
TotalTracks int `json:"total_tracks"`
|
TotalTracks int `json:"total_tracks"`
|
||||||
ReleaseDate string `json:"release_date"`
|
ReleaseDate string `json:"release_date"`
|
||||||
ItemID string `json:"item_id"` // Unique ID for progress tracking
|
ItemID string `json:"item_id"` // Unique ID for progress tracking
|
||||||
DurationMS int `json:"duration_ms"` // Expected duration in milliseconds (for verification)
|
DurationMS int `json:"duration_ms"` // Expected duration in milliseconds (for verification)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,6 +155,7 @@ type DownloadResponse struct {
|
|||||||
ReleaseDate string `json:"release_date,omitempty"`
|
ReleaseDate string `json:"release_date,omitempty"`
|
||||||
TrackNumber int `json:"track_number,omitempty"`
|
TrackNumber int `json:"track_number,omitempty"`
|
||||||
DiscNumber int `json:"disc_number,omitempty"`
|
DiscNumber int `json:"disc_number,omitempty"`
|
||||||
|
ISRC string `json:"isrc,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DownloadResult is a generic result type for all downloaders
|
// DownloadResult is a generic result type for all downloaders
|
||||||
@@ -169,6 +170,7 @@ type DownloadResult struct {
|
|||||||
ReleaseDate string
|
ReleaseDate string
|
||||||
TrackNumber int
|
TrackNumber int
|
||||||
DiscNumber int
|
DiscNumber int
|
||||||
|
ISRC string
|
||||||
}
|
}
|
||||||
|
|
||||||
// DownloadTrack downloads a track from the specified service
|
// DownloadTrack downloads a track from the specified service
|
||||||
@@ -204,6 +206,7 @@ func DownloadTrack(requestJSON string) (string, error) {
|
|||||||
ReleaseDate: tidalResult.ReleaseDate,
|
ReleaseDate: tidalResult.ReleaseDate,
|
||||||
TrackNumber: tidalResult.TrackNumber,
|
TrackNumber: tidalResult.TrackNumber,
|
||||||
DiscNumber: tidalResult.DiscNumber,
|
DiscNumber: tidalResult.DiscNumber,
|
||||||
|
ISRC: tidalResult.ISRC,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
err = tidalErr
|
err = tidalErr
|
||||||
@@ -220,6 +223,7 @@ func DownloadTrack(requestJSON string) (string, error) {
|
|||||||
ReleaseDate: qobuzResult.ReleaseDate,
|
ReleaseDate: qobuzResult.ReleaseDate,
|
||||||
TrackNumber: qobuzResult.TrackNumber,
|
TrackNumber: qobuzResult.TrackNumber,
|
||||||
DiscNumber: qobuzResult.DiscNumber,
|
DiscNumber: qobuzResult.DiscNumber,
|
||||||
|
ISRC: qobuzResult.ISRC,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
err = qobuzErr
|
err = qobuzErr
|
||||||
@@ -236,6 +240,7 @@ func DownloadTrack(requestJSON string) (string, error) {
|
|||||||
ReleaseDate: amazonResult.ReleaseDate,
|
ReleaseDate: amazonResult.ReleaseDate,
|
||||||
TrackNumber: amazonResult.TrackNumber,
|
TrackNumber: amazonResult.TrackNumber,
|
||||||
DiscNumber: amazonResult.DiscNumber,
|
DiscNumber: amazonResult.DiscNumber,
|
||||||
|
ISRC: amazonResult.ISRC,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
err = amazonErr
|
err = amazonErr
|
||||||
@@ -264,6 +269,13 @@ func DownloadTrack(requestJSON string) (string, error) {
|
|||||||
ActualBitDepth: result.BitDepth,
|
ActualBitDepth: result.BitDepth,
|
||||||
ActualSampleRate: result.SampleRate,
|
ActualSampleRate: result.SampleRate,
|
||||||
Service: req.Service,
|
Service: req.Service,
|
||||||
|
Title: result.Title,
|
||||||
|
Artist: result.Artist,
|
||||||
|
Album: result.Album,
|
||||||
|
ReleaseDate: result.ReleaseDate,
|
||||||
|
TrackNumber: result.TrackNumber,
|
||||||
|
DiscNumber: result.DiscNumber,
|
||||||
|
ISRC: result.ISRC,
|
||||||
}
|
}
|
||||||
jsonBytes, _ := json.Marshal(resp)
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
@@ -292,6 +304,7 @@ func DownloadTrack(requestJSON string) (string, error) {
|
|||||||
ReleaseDate: result.ReleaseDate,
|
ReleaseDate: result.ReleaseDate,
|
||||||
TrackNumber: result.TrackNumber,
|
TrackNumber: result.TrackNumber,
|
||||||
DiscNumber: result.DiscNumber,
|
DiscNumber: result.DiscNumber,
|
||||||
|
ISRC: result.ISRC,
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonBytes, _ := json.Marshal(resp)
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
@@ -314,7 +327,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
req.OutputDir = strings.TrimSpace(req.OutputDir)
|
req.OutputDir = strings.TrimSpace(req.OutputDir)
|
||||||
|
|
||||||
// Build service order starting with preferred service
|
// Build service order starting with preferred service
|
||||||
allServices := []string{"qobuz", "tidal", "amazon"}
|
allServices := []string{"tidal", "qobuz", "amazon"}
|
||||||
preferredService := req.Service
|
preferredService := req.Service
|
||||||
if preferredService == "" {
|
if preferredService == "" {
|
||||||
preferredService = "tidal"
|
preferredService = "tidal"
|
||||||
@@ -355,6 +368,7 @@ 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)
|
fmt.Printf("[DownloadWithFallback] Tidal error: %v\n", tidalErr)
|
||||||
@@ -364,9 +378,16 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
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)
|
fmt.Printf("[DownloadWithFallback] Qobuz error: %v\n", qobuzErr)
|
||||||
@@ -385,6 +406,7 @@ 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)
|
fmt.Printf("[DownloadWithFallback] Amazon error: %v\n", amazonErr)
|
||||||
@@ -410,6 +432,13 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
ActualBitDepth: result.BitDepth,
|
ActualBitDepth: result.BitDepth,
|
||||||
ActualSampleRate: result.SampleRate,
|
ActualSampleRate: result.SampleRate,
|
||||||
Service: service,
|
Service: service,
|
||||||
|
Title: result.Title,
|
||||||
|
Artist: result.Artist,
|
||||||
|
Album: result.Album,
|
||||||
|
ReleaseDate: result.ReleaseDate,
|
||||||
|
TrackNumber: result.TrackNumber,
|
||||||
|
DiscNumber: result.DiscNumber,
|
||||||
|
ISRC: result.ISRC,
|
||||||
}
|
}
|
||||||
jsonBytes, _ := json.Marshal(resp)
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
@@ -432,6 +461,13 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
ActualBitDepth: result.BitDepth,
|
ActualBitDepth: result.BitDepth,
|
||||||
ActualSampleRate: result.SampleRate,
|
ActualSampleRate: result.SampleRate,
|
||||||
Service: service,
|
Service: service,
|
||||||
|
Title: result.Title,
|
||||||
|
Artist: result.Artist,
|
||||||
|
Album: result.Album,
|
||||||
|
ReleaseDate: result.ReleaseDate,
|
||||||
|
TrackNumber: result.TrackNumber,
|
||||||
|
DiscNumber: result.DiscNumber,
|
||||||
|
ISRC: result.ISRC,
|
||||||
}
|
}
|
||||||
jsonBytes, _ := json.Marshal(resp)
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
@@ -477,6 +513,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)
|
||||||
@@ -907,19 +981,19 @@ func errorResponse(msg string) (string, error) {
|
|||||||
lowerMsg := strings.ToLower(msg)
|
lowerMsg := strings.ToLower(msg)
|
||||||
|
|
||||||
if strings.Contains(lowerMsg, "not found") ||
|
if strings.Contains(lowerMsg, "not found") ||
|
||||||
strings.Contains(lowerMsg, "not available") ||
|
strings.Contains(lowerMsg, "not available") ||
|
||||||
strings.Contains(lowerMsg, "no results") ||
|
strings.Contains(lowerMsg, "no results") ||
|
||||||
strings.Contains(lowerMsg, "track not found") ||
|
strings.Contains(lowerMsg, "track not found") ||
|
||||||
strings.Contains(lowerMsg, "all services failed") {
|
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"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+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 ""
|
||||||
|
|||||||
+22
-7
@@ -132,8 +132,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 +194,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) {
|
||||||
|
fmt.Printf("[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 +223,8 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fmt.Printf("[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 +233,8 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fmt.Printf("[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,8 +244,8 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -392,7 +398,7 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
|||||||
if durationDiff < 0 {
|
if durationDiff < 0 {
|
||||||
durationDiff = -durationDiff
|
durationDiff = -durationDiff
|
||||||
}
|
}
|
||||||
if durationDiff <= 30 {
|
if durationDiff <= 10 {
|
||||||
durationMatches = append(durationMatches, track)
|
durationMatches = append(durationMatches, track)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -592,6 +598,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
|
||||||
@@ -624,6 +631,7 @@ 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 != "" {
|
||||||
|
fmt.Printf("[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
|
||||||
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
|
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
|
||||||
@@ -731,11 +739,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,
|
||||||
@@ -780,5 +794,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
|
||||||
}
|
}
|
||||||
|
|||||||
+54
-16
@@ -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()
|
||||||
@@ -478,6 +477,33 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
|||||||
|
|
||||||
if len(result.Items) > 0 {
|
if len(result.Items) > 0 {
|
||||||
fmt.Printf("[Tidal] Found %d results for '%s'\n", len(result.Items), cleanQuery)
|
fmt.Printf("[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 {
|
||||||
|
fmt.Printf("[Tidal] ✓ ISRC match: '%s' (duration verified)\n", track.Title)
|
||||||
|
return track, nil
|
||||||
|
}
|
||||||
|
// Duration mismatch, continue searching
|
||||||
|
fmt.Printf("[Tidal] ISRC match but duration mismatch (expected %ds, got %ds), continuing...\n",
|
||||||
|
expectedDuration, track.Duration)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("[Tidal] ✓ ISRC match: '%s'\n", track.Title)
|
||||||
|
return track, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
allTracks = append(allTracks, result.Items...)
|
allTracks = append(allTracks, result.Items...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -1077,6 +1101,7 @@ type TidalDownloadResult struct {
|
|||||||
ReleaseDate string
|
ReleaseDate string
|
||||||
TrackNumber int
|
TrackNumber int
|
||||||
DiscNumber int
|
DiscNumber int
|
||||||
|
ISRC string
|
||||||
}
|
}
|
||||||
|
|
||||||
// artistsMatch checks if the artist names are similar enough
|
// artistsMatch checks if the artist names are similar enough
|
||||||
@@ -1164,13 +1189,13 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)
|
fmt.Printf("[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
|
||||||
tidalArtist := track.Artist.Name
|
tidalArtist := track.Artist.Name
|
||||||
if len(track.Artists) > 0 {
|
if len(track.Artists) > 0 {
|
||||||
var artistNames []string
|
var artistNames []string
|
||||||
@@ -1190,7 +1215,19 @@ 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")
|
fmt.Printf("[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:")
|
||||||
|
fmt.Printf("[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)
|
||||||
@@ -1381,10 +1418,10 @@ 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
|
||||||
@@ -1446,5 +1483,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
|
||||||
|
|||||||
@@ -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.0';
|
||||||
static const String buildNumber = '45';
|
static const String buildNumber = '46';
|
||||||
static const String fullVersion = '$version+$buildNumber';
|
static const String fullVersion = '$version+$buildNumber';
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ class AppSettings {
|
|||||||
final String metadataSource; // spotify, deezer - source for search and metadata
|
final String metadataSource; // spotify, deezer - source for search and metadata
|
||||||
|
|
||||||
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 = '',
|
||||||
|
|||||||
@@ -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?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -763,7 +773,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
if (track.coverUrl != null && track.coverUrl!.isNotEmpty) {
|
if (track.coverUrl != null && track.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
|
||||||
@@ -822,6 +832,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
metadata['ISRC'] = track.isrc!;
|
metadata['ISRC'] = track.isrc!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_log.d('Metadata map content: $metadata');
|
||||||
|
|
||||||
// Fetch Lyrics (Critical for M4A->FLAC conversion parity)
|
// Fetch Lyrics (Critical for M4A->FLAC conversion parity)
|
||||||
// Since we are in the Flutter context, we can call the bridge to get lyrics
|
// Since we are in the Flutter context, we can call the bridge to get lyrics
|
||||||
// This ensures even converted files have lyrics embedded if available
|
// This ensures even converted files have lyrics embedded if available
|
||||||
@@ -1094,40 +1106,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)
|
||||||
try {
|
// ISRC is critical for accurate track matching on streaming services
|
||||||
if (trackToDownload.id.startsWith('deezer:')) {
|
final needsEnrichment = trackToDownload.id.startsWith('deezer:') &&
|
||||||
_log.d('Enriching incomplete metadata for Deezer track: ${trackToDownload.name}');
|
(trackToDownload.isrc == null || trackToDownload.isrc!.isEmpty ||
|
||||||
final rawId = trackToDownload.id.split(':')[1];
|
trackToDownload.trackNumber == null || trackToDownload.trackNumber == 0);
|
||||||
final fullData = await PlatformBridge.getDeezerMetadata('track', rawId);
|
|
||||||
|
|
||||||
if (fullData.containsKey('track')) {
|
if (needsEnrichment) {
|
||||||
final fullTrack = Track.fromJson(fullData['track'] as Map<String, dynamic>);
|
try {
|
||||||
// Merge with existing (keep override quality/service if any, but update metadata)
|
_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];
|
||||||
|
_log.d('Fetching full metadata for Deezer ID: $rawId');
|
||||||
|
final fullData = await PlatformBridge.getDeezerMetadata('track', rawId);
|
||||||
|
_log.d('Got response keys: ${fullData.keys.toList()}');
|
||||||
|
|
||||||
|
if (fullData.containsKey('track')) {
|
||||||
|
// 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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1257,7 +1284,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 +1301,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 +1371,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 +1387,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,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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)),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ 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 {
|
||||||
@@ -248,6 +248,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 +345,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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+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.0+46
|
||||||
|
|
||||||
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.0+46
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.0
|
sdk: ^3.10.0
|
||||||
|
|||||||
Reference in New Issue
Block a user