mirror of
https://github.com/spotiflacapp/SpotiFLAC-Mobile.git
synced 2026-06-01 03:15:17 +07:00
v2.0.2: Quality display, fallback fix, Open in Spotify fix
- Add actual quality display (bit depth/sample rate) in history and metadata - Add quality disclaimer in quality picker - Fix fallback service display showing wrong service - Fix Open in Spotify not opening app correctly - Remove romaji conversion feature - Amazon now reads quality from FLAC file
This commit is contained in:
parent
8ce5e958ee
commit
794486a200
24 changed files with 499 additions and 459 deletions
24
CHANGELOG.md
24
CHANGELOG.md
|
|
@ -1,5 +1,29 @@
|
|||
# Changelog
|
||||
|
||||
## [2.0.2] - 2026-01-03
|
||||
|
||||
### Added
|
||||
- **Actual Quality Display**: Shows real audio quality (bit depth/sample rate) after download
|
||||
- Quality badge on download history items (e.g., "24-bit", "16-bit")
|
||||
- Full quality info in Track Metadata screen (e.g., "24-bit/96kHz")
|
||||
- Tertiary color highlight for Hi-Res (24-bit) downloads
|
||||
- **Quality Disclaimer**: Added note in quality picker explaining that actual quality depends on track availability
|
||||
- **Instant Lyrics Loading**: Lyrics now load from embedded file first (instant) before falling back to internet fetch
|
||||
|
||||
### Fixed
|
||||
- **Fallback Service Display**: Fixed download history showing wrong service when fallback occurs (e.g., showing "TIDAL" when actually downloaded from "QOBUZ")
|
||||
- **Open in Spotify**: Fixed "Open in Spotify" button not opening Spotify app correctly
|
||||
|
||||
### Removed
|
||||
- **Romaji Conversion**: Removed Japanese lyrics to romaji conversion feature (Kanji not supported, results were incomplete)
|
||||
|
||||
### Technical
|
||||
- Go backend now returns `actual_bit_depth` and `actual_sample_rate` in download response
|
||||
- Go backend now returns `service` field indicating actual service used (important for fallback)
|
||||
- Tidal API v2 response provides exact quality info
|
||||
- Qobuz uses track metadata for quality info
|
||||
- Amazon now reads quality from downloaded FLAC file (previously returned unknown)
|
||||
|
||||
## [2.0.1] - 2026-01-03
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -157,8 +157,9 @@ class MainActivity: FlutterActivity() {
|
|||
val spotifyId = call.argument<String>("spotify_id") ?: ""
|
||||
val trackName = call.argument<String>("track_name") ?: ""
|
||||
val artistName = call.argument<String>("artist_name") ?: ""
|
||||
val filePath = call.argument<String>("file_path") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getLyricsLRC(spotifyId, trackName, artistName)
|
||||
Gobackend.getLyricsLRC(spotifyId, trackName, artistName, filePath)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -254,38 +254,45 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
|
|||
return nil
|
||||
}
|
||||
|
||||
// AmazonDownloadResult contains download result with quality info
|
||||
type AmazonDownloadResult struct {
|
||||
FilePath string
|
||||
BitDepth int
|
||||
SampleRate int
|
||||
}
|
||||
|
||||
// downloadFromAmazon downloads a track using the request parameters
|
||||
// Uses DoubleDouble service (same as PC version)
|
||||
func downloadFromAmazon(req DownloadRequest) (string, error) {
|
||||
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
downloader := NewAmazonDownloader()
|
||||
|
||||
// Check for existing file first
|
||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||
return "EXISTS:" + existingFile, nil
|
||||
return AmazonDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||
}
|
||||
|
||||
// Get Amazon URL from SongLink
|
||||
songlink := NewSongLinkClient()
|
||||
availability, err := songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to check Amazon availability via SongLink: %w", err)
|
||||
return AmazonDownloadResult{}, fmt.Errorf("failed to check Amazon availability via SongLink: %w", err)
|
||||
}
|
||||
|
||||
if !availability.Amazon || availability.AmazonURL == "" {
|
||||
return "", fmt.Errorf("track not available on Amazon Music (SongLink returned no Amazon URL)")
|
||||
return AmazonDownloadResult{}, fmt.Errorf("track not available on Amazon Music (SongLink returned no Amazon URL)")
|
||||
}
|
||||
|
||||
// Create output directory if needed
|
||||
if req.OutputDir != "." {
|
||||
if err := os.MkdirAll(req.OutputDir, 0755); err != nil {
|
||||
return "", fmt.Errorf("failed to create output directory: %w", err)
|
||||
return AmazonDownloadResult{}, fmt.Errorf("failed to create output directory: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Download using DoubleDouble service (same as PC)
|
||||
downloadURL, trackName, artistName, err := downloader.downloadFromDoubleDoubleService(availability.AmazonURL, req.OutputDir)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get download URL: %w", err)
|
||||
return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
||||
}
|
||||
|
||||
// Build filename using Spotify metadata (more accurate)
|
||||
|
|
@ -302,12 +309,12 @@ func downloadFromAmazon(req DownloadRequest) (string, error) {
|
|||
|
||||
// Check if file already exists
|
||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||
return "EXISTS:" + outputPath, nil
|
||||
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||
}
|
||||
|
||||
// Download file with item ID for progress tracking
|
||||
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
|
||||
return "", fmt.Errorf("download failed: %w", err)
|
||||
return AmazonDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
|
||||
// Set progress to 100% and status to finalizing (before embedding)
|
||||
|
|
@ -363,17 +370,6 @@ func downloadFromAmazon(req DownloadRequest) (string, error) {
|
|||
fmt.Println("[Amazon] No lyrics found for this track")
|
||||
} else {
|
||||
fmt.Printf("[Amazon] Lyrics found (%d lines), embedding...\n", len(lyrics.Lines))
|
||||
|
||||
// Convert Japanese lyrics to romaji if enabled
|
||||
if req.ConvertLyricsToRomaji {
|
||||
for i := range lyrics.Lines {
|
||||
if ContainsKana(lyrics.Lines[i].Words) {
|
||||
lyrics.Lines[i].Words = ToRomaji(lyrics.Lines[i].Words)
|
||||
}
|
||||
}
|
||||
fmt.Println("[Amazon] Converted Japanese lyrics to romaji")
|
||||
}
|
||||
|
||||
lrcContent := convertToLRC(lyrics)
|
||||
if embedErr := EmbedLyrics(outputPath, lrcContent); embedErr != nil {
|
||||
fmt.Printf("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||
|
|
@ -384,5 +380,24 @@ func downloadFromAmazon(req DownloadRequest) (string, error) {
|
|||
}
|
||||
|
||||
fmt.Println("[Amazon] ✓ Downloaded successfully from Amazon Music")
|
||||
return outputPath, nil
|
||||
|
||||
// Read actual quality from the downloaded FLAC file
|
||||
// Amazon API doesn't provide quality info, but we can read it from the file itself
|
||||
quality, err := GetAudioQuality(outputPath)
|
||||
if err != nil {
|
||||
fmt.Printf("[Amazon] Warning: couldn't read quality from file: %v\n", err)
|
||||
// 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)
|
||||
return AmazonDownloadResult{
|
||||
FilePath: outputPath,
|
||||
BitDepth: quality.BitDepth,
|
||||
SampleRate: quality.SampleRate,
|
||||
}, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -122,7 +122,6 @@ type DownloadRequest struct {
|
|||
Quality string `json:"quality"` // LOSSLESS, HI_RES, HI_RES_LOSSLESS
|
||||
EmbedLyrics bool `json:"embed_lyrics"`
|
||||
EmbedMaxQualityCover bool `json:"embed_max_quality_cover"`
|
||||
ConvertLyricsToRomaji bool `json:"convert_lyrics_to_romaji"`
|
||||
TrackNumber int `json:"track_number"`
|
||||
DiscNumber int `json:"disc_number"`
|
||||
TotalTracks int `json:"total_tracks"`
|
||||
|
|
@ -137,6 +136,17 @@ type DownloadResponse struct {
|
|||
FilePath string `json:"file_path,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
AlreadyExists bool `json:"already_exists,omitempty"`
|
||||
// Actual quality info from the source
|
||||
ActualBitDepth int `json:"actual_bit_depth,omitempty"`
|
||||
ActualSampleRate int `json:"actual_sample_rate,omitempty"`
|
||||
Service string `json:"service,omitempty"` // Actual service used (for fallback)
|
||||
}
|
||||
|
||||
// DownloadResult is a generic result type for all downloaders
|
||||
type DownloadResult struct {
|
||||
FilePath string
|
||||
BitDepth int
|
||||
SampleRate int
|
||||
}
|
||||
|
||||
// DownloadTrack downloads a track from the specified service
|
||||
|
|
@ -155,16 +165,40 @@ func DownloadTrack(requestJSON string) (string, error) {
|
|||
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
|
||||
req.OutputDir = strings.TrimSpace(req.OutputDir)
|
||||
|
||||
var filePath string
|
||||
var result DownloadResult
|
||||
var err error
|
||||
|
||||
switch req.Service {
|
||||
case "tidal":
|
||||
filePath, err = downloadFromTidal(req)
|
||||
tidalResult, tidalErr := downloadFromTidal(req)
|
||||
if tidalErr == nil {
|
||||
result = DownloadResult{
|
||||
FilePath: tidalResult.FilePath,
|
||||
BitDepth: tidalResult.BitDepth,
|
||||
SampleRate: tidalResult.SampleRate,
|
||||
}
|
||||
}
|
||||
err = tidalErr
|
||||
case "qobuz":
|
||||
filePath, err = downloadFromQobuz(req)
|
||||
qobuzResult, qobuzErr := downloadFromQobuz(req)
|
||||
if qobuzErr == nil {
|
||||
result = DownloadResult{
|
||||
FilePath: qobuzResult.FilePath,
|
||||
BitDepth: qobuzResult.BitDepth,
|
||||
SampleRate: qobuzResult.SampleRate,
|
||||
}
|
||||
}
|
||||
err = qobuzErr
|
||||
case "amazon":
|
||||
filePath, err = downloadFromAmazon(req)
|
||||
amazonResult, amazonErr := downloadFromAmazon(req)
|
||||
if amazonErr == nil {
|
||||
result = DownloadResult{
|
||||
FilePath: amazonResult.FilePath,
|
||||
BitDepth: amazonResult.BitDepth,
|
||||
SampleRate: amazonResult.SampleRate,
|
||||
}
|
||||
}
|
||||
err = amazonErr
|
||||
default:
|
||||
return errorResponse("Unknown service: " + req.Service)
|
||||
}
|
||||
|
|
@ -174,21 +208,25 @@ func DownloadTrack(requestJSON string) (string, error) {
|
|||
}
|
||||
|
||||
// Check if file already exists
|
||||
if len(filePath) > 7 && filePath[:7] == "EXISTS:" {
|
||||
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
|
||||
resp := DownloadResponse{
|
||||
Success: true,
|
||||
Message: "File already exists",
|
||||
FilePath: filePath[7:],
|
||||
FilePath: result.FilePath[7:],
|
||||
AlreadyExists: true,
|
||||
Service: req.Service,
|
||||
}
|
||||
jsonBytes, _ := json.Marshal(resp)
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
resp := DownloadResponse{
|
||||
Success: true,
|
||||
Message: "Download complete",
|
||||
FilePath: filePath,
|
||||
Success: true,
|
||||
Message: "Download complete",
|
||||
FilePath: result.FilePath,
|
||||
ActualBitDepth: result.BitDepth,
|
||||
ActualSampleRate: result.SampleRate,
|
||||
Service: req.Service,
|
||||
}
|
||||
|
||||
jsonBytes, _ := json.Marshal(resp)
|
||||
|
|
@ -230,35 +268,63 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||
for _, service := range services {
|
||||
req.Service = service
|
||||
|
||||
var filePath string
|
||||
var result DownloadResult
|
||||
var err error
|
||||
|
||||
switch service {
|
||||
case "tidal":
|
||||
filePath, err = downloadFromTidal(req)
|
||||
tidalResult, tidalErr := downloadFromTidal(req)
|
||||
if tidalErr == nil {
|
||||
result = DownloadResult{
|
||||
FilePath: tidalResult.FilePath,
|
||||
BitDepth: tidalResult.BitDepth,
|
||||
SampleRate: tidalResult.SampleRate,
|
||||
}
|
||||
}
|
||||
err = tidalErr
|
||||
case "qobuz":
|
||||
filePath, err = downloadFromQobuz(req)
|
||||
qobuzResult, qobuzErr := downloadFromQobuz(req)
|
||||
if qobuzErr == nil {
|
||||
result = DownloadResult{
|
||||
FilePath: qobuzResult.FilePath,
|
||||
BitDepth: qobuzResult.BitDepth,
|
||||
SampleRate: qobuzResult.SampleRate,
|
||||
}
|
||||
}
|
||||
err = qobuzErr
|
||||
case "amazon":
|
||||
filePath, err = downloadFromAmazon(req)
|
||||
amazonResult, amazonErr := downloadFromAmazon(req)
|
||||
if amazonErr == nil {
|
||||
result = DownloadResult{
|
||||
FilePath: amazonResult.FilePath,
|
||||
BitDepth: amazonResult.BitDepth,
|
||||
SampleRate: amazonResult.SampleRate,
|
||||
}
|
||||
}
|
||||
err = amazonErr
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
// Check if file already exists
|
||||
if len(filePath) > 7 && filePath[:7] == "EXISTS:" {
|
||||
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
|
||||
resp := DownloadResponse{
|
||||
Success: true,
|
||||
Message: "File already exists",
|
||||
FilePath: filePath[7:],
|
||||
FilePath: result.FilePath[7:],
|
||||
AlreadyExists: true,
|
||||
Service: service,
|
||||
}
|
||||
jsonBytes, _ := json.Marshal(resp)
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
resp := DownloadResponse{
|
||||
Success: true,
|
||||
Message: "Downloaded from " + service,
|
||||
FilePath: filePath,
|
||||
Success: true,
|
||||
Message: "Downloaded from " + service,
|
||||
FilePath: result.FilePath,
|
||||
ActualBitDepth: result.BitDepth,
|
||||
ActualSampleRate: result.SampleRate,
|
||||
Service: service,
|
||||
}
|
||||
jsonBytes, _ := json.Marshal(resp)
|
||||
return string(jsonBytes), nil
|
||||
|
|
@ -367,14 +433,24 @@ func FetchLyrics(spotifyID, trackName, artistName string) (string, error) {
|
|||
}
|
||||
|
||||
// GetLyricsLRC fetches lyrics and converts to LRC format string
|
||||
func GetLyricsLRC(spotifyID, trackName, artistName string) (string, error) {
|
||||
// First tries to extract from file, then falls back to fetching from internet
|
||||
func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string) (string, error) {
|
||||
// Try to extract from file first (much faster)
|
||||
if filePath != "" {
|
||||
lyrics, err := ExtractLyrics(filePath)
|
||||
if err == nil && lyrics != "" {
|
||||
return lyrics, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to fetching from internet
|
||||
client := NewLyricsClient()
|
||||
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName)
|
||||
lyricsData, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
lrcContent := convertToLRC(lyrics)
|
||||
lrcContent := convertToLRC(lyricsData)
|
||||
return lrcContent, nil
|
||||
}
|
||||
|
||||
|
|
@ -394,12 +470,6 @@ func EmbedLyricsToFile(filePath, lyrics string) (string, error) {
|
|||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// ConvertToRomaji converts Japanese kana (Hiragana/Katakana) to romaji
|
||||
// Kanji characters are preserved as-is
|
||||
func ConvertToRomaji(text string) string {
|
||||
return ToRomaji(text)
|
||||
}
|
||||
|
||||
func errorResponse(msg string) (string, error) {
|
||||
resp := DownloadResponse{
|
||||
Success: false,
|
||||
|
|
|
|||
|
|
@ -335,3 +335,92 @@ func EmbedLyrics(filePath string, lyrics string) error {
|
|||
|
||||
return f.Save(filePath)
|
||||
}
|
||||
|
||||
// ExtractLyrics extracts embedded lyrics from a FLAC file
|
||||
func ExtractLyrics(filePath string) (string, error) {
|
||||
f, err := flac.ParseFile(filePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse FLAC file: %w", err)
|
||||
}
|
||||
|
||||
for _, meta := range f.Meta {
|
||||
if meta.Type == flac.VorbisComment {
|
||||
cmt, err := flacvorbis.ParseFromMetaDataBlock(*meta)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Try LYRICS tag first
|
||||
lyrics, err := cmt.Get("LYRICS")
|
||||
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
|
||||
return lyrics[0], nil
|
||||
}
|
||||
|
||||
// Fallback to UNSYNCEDLYRICS
|
||||
lyrics, err = cmt.Get("UNSYNCEDLYRICS")
|
||||
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
|
||||
return lyrics[0], nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no lyrics found in file")
|
||||
}
|
||||
|
||||
// AudioQuality represents audio quality info from a FLAC file
|
||||
type AudioQuality struct {
|
||||
BitDepth int `json:"bit_depth"`
|
||||
SampleRate int `json:"sample_rate"`
|
||||
}
|
||||
|
||||
// GetAudioQuality reads bit depth and sample rate from a FLAC file's StreamInfo block
|
||||
// FLAC StreamInfo is always the first metadata block after the 4-byte "fLaC" marker
|
||||
func GetAudioQuality(filePath string) (AudioQuality, error) {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return AudioQuality{}, fmt.Errorf("failed to open file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Read FLAC marker (4 bytes: "fLaC")
|
||||
marker := make([]byte, 4)
|
||||
if _, err := file.Read(marker); err != nil {
|
||||
return AudioQuality{}, fmt.Errorf("failed to read marker: %w", err)
|
||||
}
|
||||
if string(marker) != "fLaC" {
|
||||
return AudioQuality{}, fmt.Errorf("not a FLAC file")
|
||||
}
|
||||
|
||||
// Read metadata block header (4 bytes)
|
||||
// Byte 0: bit 7 = last block flag, bits 0-6 = block type (0 = STREAMINFO)
|
||||
// Bytes 1-3: block length (24-bit big-endian)
|
||||
header := make([]byte, 4)
|
||||
if _, err := file.Read(header); err != nil {
|
||||
return AudioQuality{}, fmt.Errorf("failed to read header: %w", err)
|
||||
}
|
||||
|
||||
blockType := header[0] & 0x7F
|
||||
if blockType != 0 {
|
||||
return AudioQuality{}, fmt.Errorf("first block is not STREAMINFO")
|
||||
}
|
||||
|
||||
// Read STREAMINFO block (34 bytes minimum)
|
||||
// Bytes 10-13 contain sample rate (20 bits), channels (3 bits), bits per sample (5 bits)
|
||||
streamInfo := make([]byte, 34)
|
||||
if _, err := file.Read(streamInfo); err != nil {
|
||||
return AudioQuality{}, fmt.Errorf("failed to read STREAMINFO: %w", err)
|
||||
}
|
||||
|
||||
// Parse sample rate (20 bits starting at byte 10)
|
||||
// Bytes 10-12: [SSSS SSSS] [SSSS SSSS] [SSSS CCCC] where S=sample rate, C=channels
|
||||
sampleRate := (int(streamInfo[10]) << 12) | (int(streamInfo[11]) << 4) | (int(streamInfo[12]) >> 4)
|
||||
|
||||
// Parse bits per sample (5 bits)
|
||||
// Byte 12 bits 0-3 and byte 13 bit 7: [.... BBBB] [B...] where B=bits per sample - 1
|
||||
bitsPerSample := ((int(streamInfo[12]) & 0x01) << 4) | (int(streamInfo[13]) >> 4) + 1
|
||||
|
||||
return AudioQuality{
|
||||
BitDepth: bitsPerSample,
|
||||
SampleRate: sampleRate,
|
||||
}, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -305,13 +305,20 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
|||
return err
|
||||
}
|
||||
|
||||
// QobuzDownloadResult contains download result with quality info
|
||||
type QobuzDownloadResult struct {
|
||||
FilePath string
|
||||
BitDepth int
|
||||
SampleRate int
|
||||
}
|
||||
|
||||
// downloadFromQobuz downloads a track using the request parameters
|
||||
func downloadFromQobuz(req DownloadRequest) (string, error) {
|
||||
func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
downloader := NewQobuzDownloader()
|
||||
|
||||
// Check for existing file first
|
||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||
return "EXISTS:" + existingFile, nil
|
||||
return QobuzDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||
}
|
||||
|
||||
var track *QobuzTrack
|
||||
|
|
@ -332,7 +339,7 @@ func downloadFromQobuz(req DownloadRequest) (string, error) {
|
|||
if err != nil {
|
||||
errMsg = err.Error()
|
||||
}
|
||||
return "", fmt.Errorf("qobuz search failed: %s", errMsg)
|
||||
return QobuzDownloadResult{}, fmt.Errorf("qobuz search failed: %s", errMsg)
|
||||
}
|
||||
|
||||
// Build filename
|
||||
|
|
@ -349,7 +356,7 @@ func downloadFromQobuz(req DownloadRequest) (string, error) {
|
|||
|
||||
// Check if file already exists
|
||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||
return "EXISTS:" + outputPath, nil
|
||||
return QobuzDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||
}
|
||||
|
||||
// Map quality from Tidal format to Qobuz format
|
||||
|
|
@ -366,15 +373,20 @@ func downloadFromQobuz(req DownloadRequest) (string, error) {
|
|||
}
|
||||
fmt.Printf("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality)
|
||||
|
||||
// Get actual quality from track metadata
|
||||
actualBitDepth := track.MaximumBitDepth
|
||||
actualSampleRate := int(track.MaximumSamplingRate * 1000) // Convert kHz to Hz
|
||||
fmt.Printf("[Qobuz] Actual quality: %d-bit/%.1fkHz\n", actualBitDepth, track.MaximumSamplingRate)
|
||||
|
||||
// Get download URL using parallel API requests
|
||||
downloadURL, err := downloader.GetDownloadURL(track.ID, qobuzQuality)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get download URL: %w", err)
|
||||
return QobuzDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
||||
}
|
||||
|
||||
// Download file with item ID for progress tracking
|
||||
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
|
||||
return "", fmt.Errorf("download failed: %w", err)
|
||||
return QobuzDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
|
||||
// Set progress to 100% and status to finalizing (before embedding)
|
||||
|
|
@ -425,17 +437,6 @@ func downloadFromQobuz(req DownloadRequest) (string, error) {
|
|||
fmt.Println("[Qobuz] No lyrics found for this track")
|
||||
} else {
|
||||
fmt.Printf("[Qobuz] Lyrics found (%d lines), embedding...\n", len(lyrics.Lines))
|
||||
|
||||
// Convert Japanese lyrics to romaji if enabled
|
||||
if req.ConvertLyricsToRomaji {
|
||||
for i := range lyrics.Lines {
|
||||
if ContainsKana(lyrics.Lines[i].Words) {
|
||||
lyrics.Lines[i].Words = ToRomaji(lyrics.Lines[i].Words)
|
||||
}
|
||||
}
|
||||
fmt.Println("[Qobuz] Converted Japanese lyrics to romaji")
|
||||
}
|
||||
|
||||
lrcContent := convertToLRC(lyrics)
|
||||
if embedErr := EmbedLyrics(outputPath, lrcContent); embedErr != nil {
|
||||
fmt.Printf("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||
|
|
@ -445,5 +446,9 @@ func downloadFromQobuz(req DownloadRequest) (string, error) {
|
|||
}
|
||||
}
|
||||
|
||||
return outputPath, nil
|
||||
return QobuzDownloadResult{
|
||||
FilePath: outputPath,
|
||||
BitDepth: actualBitDepth,
|
||||
SampleRate: actualSampleRate,
|
||||
}, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,276 +0,0 @@
|
|||
package gobackend
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// Japanese character ranges
|
||||
const (
|
||||
hiraganaStart = 0x3040
|
||||
hiraganaEnd = 0x309F
|
||||
katakanaStart = 0x30A0
|
||||
katakanaEnd = 0x30FF
|
||||
kanjiStart = 0x4E00
|
||||
kanjiEnd = 0x9FFF
|
||||
)
|
||||
|
||||
// hiraganaToRomaji maps hiragana characters to romaji
|
||||
var hiraganaToRomaji = map[rune]string{
|
||||
// Basic vowels
|
||||
'あ': "a", 'い': "i", 'う': "u", 'え': "e", 'お': "o",
|
||||
// K-row
|
||||
'か': "ka", 'き': "ki", 'く': "ku", 'け': "ke", 'こ': "ko",
|
||||
// S-row
|
||||
'さ': "sa", 'し': "shi", 'す': "su", 'せ': "se", 'そ': "so",
|
||||
// T-row
|
||||
'た': "ta", 'ち': "chi", 'つ': "tsu", 'て': "te", 'と': "to",
|
||||
// N-row
|
||||
'な': "na", 'に': "ni", 'ぬ': "nu", 'ね': "ne", 'の': "no",
|
||||
// H-row
|
||||
'は': "ha", 'ひ': "hi", 'ふ': "fu", 'へ': "he", 'ほ': "ho",
|
||||
// M-row
|
||||
'ま': "ma", 'み': "mi", 'む': "mu", 'め': "me", 'も': "mo",
|
||||
// Y-row
|
||||
'や': "ya", 'ゆ': "yu", 'よ': "yo",
|
||||
// R-row
|
||||
'ら': "ra", 'り': "ri", 'る': "ru", 'れ': "re", 'ろ': "ro",
|
||||
// W-row
|
||||
'わ': "wa", 'を': "wo",
|
||||
// N
|
||||
'ん': "n",
|
||||
// Voiced (dakuten) - G-row
|
||||
'が': "ga", 'ぎ': "gi", 'ぐ': "gu", 'げ': "ge", 'ご': "go",
|
||||
// Z-row
|
||||
'ざ': "za", 'じ': "ji", 'ず': "zu", 'ぜ': "ze", 'ぞ': "zo",
|
||||
// D-row
|
||||
'だ': "da", 'ぢ': "ji", 'づ': "zu", 'で': "de", 'ど': "do",
|
||||
// B-row
|
||||
'ば': "ba", 'び': "bi", 'ぶ': "bu", 'べ': "be", 'ぼ': "bo",
|
||||
// P-row (handakuten)
|
||||
'ぱ': "pa", 'ぴ': "pi", 'ぷ': "pu", 'ぺ': "pe", 'ぽ': "po",
|
||||
// Small characters
|
||||
'ゃ': "ya", 'ゅ': "yu", 'ょ': "yo",
|
||||
'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e", 'ぉ': "o",
|
||||
'っ': "", // Small tsu - handled specially
|
||||
// Long vowel mark
|
||||
'ー': "",
|
||||
}
|
||||
|
||||
// katakanaToRomaji maps katakana characters to romaji
|
||||
var katakanaToRomaji = map[rune]string{
|
||||
// Basic vowels
|
||||
'ア': "a", 'イ': "i", 'ウ': "u", 'エ': "e", 'オ': "o",
|
||||
// K-row
|
||||
'カ': "ka", 'キ': "ki", 'ク': "ku", 'ケ': "ke", 'コ': "ko",
|
||||
// S-row
|
||||
'サ': "sa", 'シ': "shi", 'ス': "su", 'セ': "se", 'ソ': "so",
|
||||
// T-row
|
||||
'タ': "ta", 'チ': "chi", 'ツ': "tsu", 'テ': "te", 'ト': "to",
|
||||
// N-row
|
||||
'ナ': "na", 'ニ': "ni", 'ヌ': "nu", 'ネ': "ne", 'ノ': "no",
|
||||
// H-row
|
||||
'ハ': "ha", 'ヒ': "hi", 'フ': "fu", 'ヘ': "he", 'ホ': "ho",
|
||||
// M-row
|
||||
'マ': "ma", 'ミ': "mi", 'ム': "mu", 'メ': "me", 'モ': "mo",
|
||||
// Y-row
|
||||
'ヤ': "ya", 'ユ': "yu", 'ヨ': "yo",
|
||||
// R-row
|
||||
'ラ': "ra", 'リ': "ri", 'ル': "ru", 'レ': "re", 'ロ': "ro",
|
||||
// W-row
|
||||
'ワ': "wa", 'ヲ': "wo",
|
||||
// N
|
||||
'ン': "n",
|
||||
// Voiced (dakuten) - G-row
|
||||
'ガ': "ga", 'ギ': "gi", 'グ': "gu", 'ゲ': "ge", 'ゴ': "go",
|
||||
// Z-row
|
||||
'ザ': "za", 'ジ': "ji", 'ズ': "zu", 'ゼ': "ze", 'ゾ': "zo",
|
||||
// D-row
|
||||
'ダ': "da", 'ヂ': "ji", 'ヅ': "zu", 'デ': "de", 'ド': "do",
|
||||
// B-row
|
||||
'バ': "ba", 'ビ': "bi", 'ブ': "bu", 'ベ': "be", 'ボ': "bo",
|
||||
// P-row (handakuten)
|
||||
'パ': "pa", 'ピ': "pi", 'プ': "pu", 'ペ': "pe", 'ポ': "po",
|
||||
// Small characters
|
||||
'ャ': "ya", 'ュ': "yu", 'ョ': "yo",
|
||||
'ァ': "a", 'ィ': "i", 'ゥ': "u", 'ェ': "e", 'ォ': "o",
|
||||
'ッ': "", // Small tsu - handled specially
|
||||
// Extended katakana
|
||||
'ヴ': "vu",
|
||||
// Long vowel mark
|
||||
'ー': "",
|
||||
}
|
||||
|
||||
// Extended katakana combinations (multi-character)
|
||||
var katakanaExtended = map[string]string{
|
||||
"ファ": "fa", "フィ": "fi", "フェ": "fe", "フォ": "fo",
|
||||
}
|
||||
|
||||
// Combination mappings for small ya/yu/yo
|
||||
var hiraganaCombo = map[string]string{
|
||||
"きゃ": "kya", "きゅ": "kyu", "きょ": "kyo",
|
||||
"しゃ": "sha", "しゅ": "shu", "しょ": "sho",
|
||||
"ちゃ": "cha", "ちゅ": "chu", "ちょ": "cho",
|
||||
"にゃ": "nya", "にゅ": "nyu", "にょ": "nyo",
|
||||
"ひゃ": "hya", "ひゅ": "hyu", "ひょ": "hyo",
|
||||
"みゃ": "mya", "みゅ": "myu", "みょ": "myo",
|
||||
"りゃ": "rya", "りゅ": "ryu", "りょ": "ryo",
|
||||
"ぎゃ": "gya", "ぎゅ": "gyu", "ぎょ": "gyo",
|
||||
"じゃ": "ja", "じゅ": "ju", "じょ": "jo",
|
||||
"びゃ": "bya", "びゅ": "byu", "びょ": "byo",
|
||||
"ぴゃ": "pya", "ぴゅ": "pyu", "ぴょ": "pyo",
|
||||
}
|
||||
|
||||
var katakanaCombo = map[string]string{
|
||||
"キャ": "kya", "キュ": "kyu", "キョ": "kyo",
|
||||
"シャ": "sha", "シュ": "shu", "ショ": "sho",
|
||||
"チャ": "cha", "チュ": "chu", "チョ": "cho",
|
||||
"ニャ": "nya", "ニュ": "nyu", "ニョ": "nyo",
|
||||
"ヒャ": "hya", "ヒュ": "hyu", "ヒョ": "hyo",
|
||||
"ミャ": "mya", "ミュ": "myu", "ミョ": "myo",
|
||||
"リャ": "rya", "リュ": "ryu", "リョ": "ryo",
|
||||
"ギャ": "gya", "ギュ": "gyu", "ギョ": "gyo",
|
||||
"ジャ": "ja", "ジュ": "ju", "ジョ": "jo",
|
||||
"ビャ": "bya", "ビュ": "byu", "ビョ": "byo",
|
||||
"ピャ": "pya", "ピュ": "pyu", "ピョ": "pyo",
|
||||
// Extended katakana combinations
|
||||
"ティ": "ti", "ディ": "di",
|
||||
"トゥ": "tu", "ドゥ": "du",
|
||||
"ファ": "fa", "フィ": "fi", "フェ": "fe", "フォ": "fo",
|
||||
"ウィ": "wi", "ウェ": "we", "ウォ": "wo",
|
||||
"ヴァ": "va", "ヴィ": "vi", "ヴェ": "ve", "ヴォ": "vo",
|
||||
}
|
||||
|
||||
// ContainsJapanese checks if a string contains Japanese characters (Hiragana, Katakana, or Kanji)
|
||||
func ContainsJapanese(s string) bool {
|
||||
for _, r := range s {
|
||||
if isHiragana(r) || isKatakana(r) || isKanji(r) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ContainsKana checks if a string contains Hiragana or Katakana (convertible to romaji)
|
||||
func ContainsKana(s string) bool {
|
||||
for _, r := range s {
|
||||
if isHiragana(r) || isKatakana(r) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isHiragana(r rune) bool {
|
||||
return r >= hiraganaStart && r <= hiraganaEnd
|
||||
}
|
||||
|
||||
func isKatakana(r rune) bool {
|
||||
return r >= katakanaStart && r <= katakanaEnd
|
||||
}
|
||||
|
||||
func isKanji(r rune) bool {
|
||||
return r >= kanjiStart && r <= kanjiEnd
|
||||
}
|
||||
|
||||
// ToRomaji converts Japanese kana (Hiragana/Katakana) to romaji
|
||||
// Kanji characters are preserved as-is since they require dictionary lookup
|
||||
func ToRomaji(s string) string {
|
||||
if !ContainsKana(s) {
|
||||
return s
|
||||
}
|
||||
|
||||
runes := []rune(s)
|
||||
var result strings.Builder
|
||||
result.Grow(len(s) * 2) // Romaji is typically longer
|
||||
|
||||
i := 0
|
||||
for i < len(runes) {
|
||||
r := runes[i]
|
||||
|
||||
// Check for two-character combinations first
|
||||
if i+1 < len(runes) {
|
||||
combo := string(runes[i : i+2])
|
||||
if romaji, ok := hiraganaCombo[combo]; ok {
|
||||
result.WriteString(romaji)
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
if romaji, ok := katakanaCombo[combo]; ok {
|
||||
result.WriteString(romaji)
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Handle small tsu (っ/ッ) - doubles the next consonant
|
||||
if r == 'っ' || r == 'ッ' {
|
||||
if i+1 < len(runes) {
|
||||
nextRune := runes[i+1]
|
||||
var nextRomaji string
|
||||
if romaji, ok := hiraganaToRomaji[nextRune]; ok {
|
||||
nextRomaji = romaji
|
||||
} else if romaji, ok := katakanaToRomaji[nextRune]; ok {
|
||||
nextRomaji = romaji
|
||||
}
|
||||
if len(nextRomaji) > 0 {
|
||||
result.WriteByte(nextRomaji[0]) // Double the consonant
|
||||
}
|
||||
}
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle long vowel mark (ー)
|
||||
if r == 'ー' {
|
||||
// Extend the previous vowel
|
||||
resultStr := result.String()
|
||||
if len(resultStr) > 0 {
|
||||
lastChar := resultStr[len(resultStr)-1]
|
||||
if lastChar == 'a' || lastChar == 'i' || lastChar == 'u' || lastChar == 'e' || lastChar == 'o' {
|
||||
result.WriteByte(lastChar)
|
||||
}
|
||||
}
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Single character conversion
|
||||
if romaji, ok := hiraganaToRomaji[r]; ok {
|
||||
result.WriteString(romaji)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
if romaji, ok := katakanaToRomaji[r]; ok {
|
||||
result.WriteString(romaji)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Keep non-Japanese characters as-is
|
||||
if unicode.IsSpace(r) {
|
||||
result.WriteRune(' ')
|
||||
} else {
|
||||
result.WriteRune(r)
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// GetRomajiVariants returns search variants for Japanese text
|
||||
// Returns the original string plus romaji version if applicable
|
||||
func GetRomajiVariants(s string) []string {
|
||||
variants := []string{s}
|
||||
|
||||
if ContainsKana(s) {
|
||||
romaji := ToRomaji(s)
|
||||
if romaji != s && strings.TrimSpace(romaji) != "" {
|
||||
variants = append(variants, romaji)
|
||||
}
|
||||
}
|
||||
|
||||
return variants
|
||||
}
|
||||
|
|
@ -335,33 +335,7 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
|||
queries = append(queries, trackName)
|
||||
}
|
||||
|
||||
// Strategy 3: Romaji versions if Japanese detected
|
||||
if ContainsJapanese(trackName) || ContainsJapanese(artistName) {
|
||||
// Try romaji version of track name
|
||||
if ContainsKana(trackName) {
|
||||
romajiTrack := ToRomaji(trackName)
|
||||
if romajiTrack != trackName {
|
||||
if artistName != "" {
|
||||
queries = append(queries, artistName+" "+romajiTrack)
|
||||
}
|
||||
queries = append(queries, romajiTrack)
|
||||
}
|
||||
}
|
||||
// Try romaji version of artist name
|
||||
if ContainsKana(artistName) {
|
||||
romajiArtist := ToRomaji(artistName)
|
||||
if romajiArtist != artistName {
|
||||
queries = append(queries, romajiArtist+" "+trackName)
|
||||
// Try both romaji
|
||||
if ContainsKana(trackName) {
|
||||
romajiTrack := ToRomaji(trackName)
|
||||
queries = append(queries, romajiArtist+" "+romajiTrack)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 4: Artist only as last resort
|
||||
// Strategy 3: Artist only as last resort
|
||||
if artistName != "" {
|
||||
queries = append(queries, artistName)
|
||||
}
|
||||
|
|
@ -483,11 +457,18 @@ func (t *TidalDownloader) SearchTrackByMetadata(trackName, artistName string) (*
|
|||
}
|
||||
|
||||
|
||||
// TidalDownloadInfo contains download URL and quality info
|
||||
type TidalDownloadInfo struct {
|
||||
URL string
|
||||
BitDepth int
|
||||
SampleRate int
|
||||
}
|
||||
|
||||
// getDownloadURLSequential requests download URL from APIs sequentially
|
||||
// Returns the first successful result (supports both v1 and v2 API formats)
|
||||
func getDownloadURLSequential(apis []string, trackID int64, quality string) (string, string, error) {
|
||||
func getDownloadURLSequential(apis []string, trackID int64, quality string) (string, TidalDownloadInfo, error) {
|
||||
if len(apis) == 0 {
|
||||
return "", "", fmt.Errorf("no APIs available")
|
||||
return "", TidalDownloadInfo{}, fmt.Errorf("no APIs available")
|
||||
}
|
||||
|
||||
client := NewHTTPClientWithTimeout(DefaultTimeout)
|
||||
|
|
@ -519,7 +500,12 @@ func getDownloadURLSequential(apis []string, trackID int64, quality string) (str
|
|||
// Try v2 format first (object with manifest)
|
||||
var v2Response TidalAPIResponseV2
|
||||
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
|
||||
return apiURL, "MANIFEST:" + v2Response.Data.Manifest, nil
|
||||
info := TidalDownloadInfo{
|
||||
URL: "MANIFEST:" + v2Response.Data.Manifest,
|
||||
BitDepth: v2Response.Data.BitDepth,
|
||||
SampleRate: v2Response.Data.SampleRate,
|
||||
}
|
||||
return apiURL, info, nil
|
||||
}
|
||||
|
||||
// Fallback to v1 format (array with OriginalTrackUrl)
|
||||
|
|
@ -529,7 +515,13 @@ func getDownloadURLSequential(apis []string, trackID int64, quality string) (str
|
|||
if err := json.Unmarshal(body, &v1Responses); err == nil {
|
||||
for _, item := range v1Responses {
|
||||
if item.OriginalTrackURL != "" {
|
||||
return apiURL, item.OriginalTrackURL, nil
|
||||
// v1 format doesn't have quality info, assume 16-bit/44.1kHz
|
||||
info := TidalDownloadInfo{
|
||||
URL: item.OriginalTrackURL,
|
||||
BitDepth: 16,
|
||||
SampleRate: 44100,
|
||||
}
|
||||
return apiURL, info, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -537,22 +529,22 @@ func getDownloadURLSequential(apis []string, trackID int64, quality string) (str
|
|||
errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, "no download URL or manifest in response"))
|
||||
}
|
||||
|
||||
return "", "", fmt.Errorf("all %d Tidal APIs failed. Errors: %v", len(apis), errors)
|
||||
return "", TidalDownloadInfo{}, fmt.Errorf("all %d Tidal APIs failed. Errors: %v", len(apis), errors)
|
||||
}
|
||||
|
||||
// GetDownloadURL gets download URL for a track - tries APIs sequentially
|
||||
func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string, error) {
|
||||
func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (TidalDownloadInfo, error) {
|
||||
apis := t.GetAvailableAPIs()
|
||||
if len(apis) == 0 {
|
||||
return "", fmt.Errorf("no API URL configured")
|
||||
return TidalDownloadInfo{}, fmt.Errorf("no API URL configured")
|
||||
}
|
||||
|
||||
_, downloadURL, err := getDownloadURLSequential(apis, trackID, quality)
|
||||
_, info, err := getDownloadURLSequential(apis, trackID, quality)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get download URL: %w", err)
|
||||
return TidalDownloadInfo{}, fmt.Errorf("failed to get download URL: %w", err)
|
||||
}
|
||||
|
||||
return downloadURL, nil
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// parseManifest parses Tidal manifest (supports both BTS and DASH formats)
|
||||
|
|
@ -812,13 +804,20 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
|||
return nil
|
||||
}
|
||||
|
||||
// TidalDownloadResult contains download result with quality info
|
||||
type TidalDownloadResult struct {
|
||||
FilePath string
|
||||
BitDepth int
|
||||
SampleRate int
|
||||
}
|
||||
|
||||
// downloadFromTidal downloads a track using the request parameters
|
||||
func downloadFromTidal(req DownloadRequest) (string, error) {
|
||||
func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
downloader := NewTidalDownloader()
|
||||
|
||||
// Check for existing file first
|
||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||
return "EXISTS:" + existingFile, nil
|
||||
return TidalDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||
}
|
||||
|
||||
var track *TidalTrack
|
||||
|
|
@ -851,7 +850,7 @@ func downloadFromTidal(req DownloadRequest) (string, error) {
|
|||
if err != nil {
|
||||
errMsg = err.Error()
|
||||
}
|
||||
return "", fmt.Errorf("tidal search failed: %s", errMsg)
|
||||
return TidalDownloadResult{}, fmt.Errorf("tidal search failed: %s", errMsg)
|
||||
}
|
||||
|
||||
// Build filename
|
||||
|
|
@ -868,7 +867,7 @@ func downloadFromTidal(req DownloadRequest) (string, error) {
|
|||
|
||||
// Check if file already exists
|
||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||
return "EXISTS:" + outputPath, nil
|
||||
return TidalDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||
}
|
||||
|
||||
// Determine quality to use (default to LOSSLESS if not specified)
|
||||
|
|
@ -879,14 +878,17 @@ func downloadFromTidal(req DownloadRequest) (string, error) {
|
|||
fmt.Printf("[Tidal] Using quality: %s\n", quality)
|
||||
|
||||
// Get download URL using parallel API requests
|
||||
downloadURL, err := downloader.GetDownloadURL(track.ID, quality)
|
||||
downloadInfo, err := downloader.GetDownloadURL(track.ID, quality)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get download URL: %w", err)
|
||||
return TidalDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
||||
}
|
||||
|
||||
// Log actual quality received
|
||||
fmt.Printf("[Tidal] Actual quality: %d-bit/%dHz\n", downloadInfo.BitDepth, downloadInfo.SampleRate)
|
||||
|
||||
// Download file with item ID for progress tracking
|
||||
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
|
||||
return "", fmt.Errorf("download failed: %w", err)
|
||||
if err := downloader.DownloadFile(downloadInfo.URL, outputPath, req.ItemID); err != nil {
|
||||
return TidalDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
|
||||
// Set progress to 100% and status to finalizing (before embedding)
|
||||
|
|
@ -906,7 +908,7 @@ func downloadFromTidal(req DownloadRequest) (string, error) {
|
|||
fmt.Printf("[Tidal] File saved as M4A (DASH stream): %s\n", actualOutputPath)
|
||||
} else if _, err := os.Stat(outputPath); err != nil {
|
||||
// Neither FLAC nor M4A exists
|
||||
return "", 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)
|
||||
}
|
||||
|
||||
// Embed metadata
|
||||
|
|
@ -952,17 +954,6 @@ func downloadFromTidal(req DownloadRequest) (string, error) {
|
|||
fmt.Println("[Tidal] No lyrics found for this track")
|
||||
} else {
|
||||
fmt.Printf("[Tidal] Lyrics found (%d lines), embedding...\n", len(lyrics.Lines))
|
||||
|
||||
// Convert Japanese lyrics to romaji if enabled
|
||||
if req.ConvertLyricsToRomaji {
|
||||
for i := range lyrics.Lines {
|
||||
if ContainsKana(lyrics.Lines[i].Words) {
|
||||
lyrics.Lines[i].Words = ToRomaji(lyrics.Lines[i].Words)
|
||||
}
|
||||
}
|
||||
fmt.Println("[Tidal] Converted Japanese lyrics to romaji")
|
||||
}
|
||||
|
||||
lrcContent := convertToLRC(lyrics)
|
||||
if embedErr := EmbedLyrics(actualOutputPath, lrcContent); embedErr != nil {
|
||||
fmt.Printf("[Tidal] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||
|
|
@ -975,5 +966,9 @@ func downloadFromTidal(req DownloadRequest) (string, error) {
|
|||
fmt.Printf("[Tidal] Skipping metadata embed for M4A file (will be handled after conversion): %s\n", actualOutputPath)
|
||||
}
|
||||
|
||||
return actualOutputPath, nil
|
||||
return TidalDownloadResult{
|
||||
FilePath: actualOutputPath,
|
||||
BitDepth: downloadInfo.BitDepth,
|
||||
SampleRate: downloadInfo.SampleRate,
|
||||
}, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -164,7 +164,8 @@ import Gobackend // Import Go framework
|
|||
let spotifyId = args["spotify_id"] as! String
|
||||
let trackName = args["track_name"] as! String
|
||||
let artistName = args["artist_name"] as! String
|
||||
let response = GobackendGetLyricsLRC(spotifyId, trackName, artistName, &error)
|
||||
let filePath = args["file_path"] as? String ?? ""
|
||||
let response = GobackendGetLyricsLRC(spotifyId, trackName, artistName, filePath, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
/// App version and info constants
|
||||
/// Update version here only - all other files will reference this
|
||||
class AppInfo {
|
||||
static const String version = '2.0.1';
|
||||
static const String buildNumber = '31';
|
||||
static const String version = '2.0.2';
|
||||
static const String buildNumber = '32';
|
||||
static const String fullVersion = '$version+$buildNumber';
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ class AppSettings {
|
|||
final bool checkForUpdates; // Check for updates on app start
|
||||
final bool hasSearchedBefore; // Hide helper text after first search
|
||||
final String folderOrganization; // none, artist, album, artist_album
|
||||
final bool convertLyricsToRomaji; // Convert Japanese lyrics to romaji
|
||||
final String historyViewMode; // list, grid
|
||||
final bool askQualityBeforeDownload; // Show quality picker before each download
|
||||
|
||||
|
|
@ -33,7 +32,6 @@ class AppSettings {
|
|||
this.checkForUpdates = true, // Default: enabled
|
||||
this.hasSearchedBefore = false, // Default: show helper text
|
||||
this.folderOrganization = 'none', // Default: no folder organization
|
||||
this.convertLyricsToRomaji = false, // Default: keep original Japanese
|
||||
this.historyViewMode = 'grid', // Default: grid view
|
||||
this.askQualityBeforeDownload = true, // Default: ask quality before download
|
||||
});
|
||||
|
|
@ -51,7 +49,6 @@ class AppSettings {
|
|||
bool? checkForUpdates,
|
||||
bool? hasSearchedBefore,
|
||||
String? folderOrganization,
|
||||
bool? convertLyricsToRomaji,
|
||||
String? historyViewMode,
|
||||
bool? askQualityBeforeDownload,
|
||||
}) {
|
||||
|
|
@ -68,7 +65,6 @@ class AppSettings {
|
|||
checkForUpdates: checkForUpdates ?? this.checkForUpdates,
|
||||
hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore,
|
||||
folderOrganization: folderOrganization ?? this.folderOrganization,
|
||||
convertLyricsToRomaji: convertLyricsToRomaji ?? this.convertLyricsToRomaji,
|
||||
historyViewMode: historyViewMode ?? this.historyViewMode,
|
||||
askQualityBeforeDownload: askQualityBeforeDownload ?? this.askQualityBeforeDownload,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
|||
checkForUpdates: json['checkForUpdates'] as bool? ?? true,
|
||||
hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false,
|
||||
folderOrganization: json['folderOrganization'] as String? ?? 'none',
|
||||
convertLyricsToRomaji: json['convertLyricsToRomaji'] as bool? ?? false,
|
||||
historyViewMode: json['historyViewMode'] as String? ?? 'grid',
|
||||
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
|
||||
);
|
||||
|
|
@ -38,7 +37,6 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
|||
'checkForUpdates': instance.checkForUpdates,
|
||||
'hasSearchedBefore': instance.hasSearchedBefore,
|
||||
'folderOrganization': instance.folderOrganization,
|
||||
'convertLyricsToRomaji': instance.convertLyricsToRomaji,
|
||||
'historyViewMode': instance.historyViewMode,
|
||||
'askQualityBeforeDownload': instance.askQualityBeforeDownload,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1007,7 +1007,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||
releaseDate: item.track.releaseDate,
|
||||
preferredService: item.service,
|
||||
itemId: item.id, // Pass item ID for progress tracking
|
||||
convertLyricsToRomaji: settings.convertLyricsToRomaji,
|
||||
);
|
||||
} else {
|
||||
result = await PlatformBridge.downloadTrack(
|
||||
|
|
@ -1026,7 +1025,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||
discNumber: item.track.discNumber ?? 1,
|
||||
releaseDate: item.track.releaseDate,
|
||||
itemId: item.id, // Pass item ID for progress tracking
|
||||
convertLyricsToRomaji: settings.convertLyricsToRomaji,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1036,6 +1034,20 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||
var filePath = result['file_path'] as String?;
|
||||
_log.i('Download success, file: $filePath');
|
||||
|
||||
// Get actual quality from response (if available)
|
||||
final actualBitDepth = result['actual_bit_depth'] as int?;
|
||||
final actualSampleRate = result['actual_sample_rate'] as int?;
|
||||
String actualQuality = quality; // Default to requested quality
|
||||
|
||||
if (actualBitDepth != null && actualBitDepth > 0) {
|
||||
// Format: "24-bit/96kHz" or "16-bit/44.1kHz"
|
||||
final sampleRateKHz = actualSampleRate != null && actualSampleRate > 0
|
||||
? (actualSampleRate / 1000).toStringAsFixed(actualSampleRate % 1000 == 0 ? 0 : 1)
|
||||
: '?';
|
||||
actualQuality = '$actualBitDepth-bit/${sampleRateKHz}kHz';
|
||||
_log.i('Actual quality: $actualQuality');
|
||||
}
|
||||
|
||||
// Check if file is M4A (DASH stream from Tidal) and needs remuxing to FLAC
|
||||
if (filePath != null && filePath.endsWith('.m4a')) {
|
||||
_log.d('Converting M4A to FLAC...');
|
||||
|
|
@ -1096,7 +1108,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||
discNumber: item.track.discNumber,
|
||||
duration: item.track.duration,
|
||||
releaseDate: item.track.releaseDate,
|
||||
quality: quality,
|
||||
quality: actualQuality,
|
||||
),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -89,11 +89,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||
_saveSettings();
|
||||
}
|
||||
|
||||
void setConvertLyricsToRomaji(bool enabled) {
|
||||
state = state.copyWith(convertLyricsToRomaji: enabled);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setHistoryViewMode(String mode) {
|
||||
state = state.copyWith(historyViewMode: mode);
|
||||
_saveSettings();
|
||||
|
|
|
|||
|
|
@ -349,6 +349,17 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
||||
child: Text('Select Quality', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
// Disclaimer
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
|
||||
child: Text(
|
||||
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
),
|
||||
_QualityOption(title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', icon: Icons.music_note, onTap: () { Navigator.pop(context); onSelect('LOSSLESS'); }),
|
||||
_QualityOption(title: 'Hi-Res FLAC', subtitle: '24-bit / up to 96kHz', icon: Icons.high_quality, onTap: () { Navigator.pop(context); onSelect('HI_RES'); }),
|
||||
_QualityOption(title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', icon: Icons.four_k, onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS'); }),
|
||||
|
|
|
|||
|
|
@ -222,6 +222,17 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
||||
child: Text('Select Quality', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
// Disclaimer
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
|
||||
child: Text(
|
||||
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
),
|
||||
_QualityPickerOption(
|
||||
title: 'FLAC Lossless',
|
||||
subtitle: '16-bit / 44.1kHz',
|
||||
|
|
|
|||
|
|
@ -211,6 +211,17 @@ class PlaylistScreen extends ConsumerWidget {
|
|||
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
|
||||
],
|
||||
Padding(padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), child: Text('Select Quality', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold))),
|
||||
// Disclaimer
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
|
||||
child: Text(
|
||||
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
),
|
||||
_QualityOption(title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', icon: Icons.music_note, onTap: () { Navigator.pop(context); onSelect('LOSSLESS'); }),
|
||||
_QualityOption(title: 'Hi-Res FLAC', subtitle: '24-bit / up to 96kHz', icon: Icons.high_quality, onTap: () { Navigator.pop(context); onSelect('HI_RES'); }),
|
||||
_QualityOption(title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', icon: Icons.four_k, onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS'); }),
|
||||
|
|
|
|||
|
|
@ -558,6 +558,31 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||
),
|
||||
),
|
||||
),
|
||||
// Quality badge (top-left)
|
||||
if (item.quality != null && item.quality!.contains('bit'))
|
||||
Positioned(
|
||||
left: 4,
|
||||
top: 4,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: item.quality!.startsWith('24')
|
||||
? colorScheme.tertiary
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
item.quality!.split('/').first, // Just show "24-bit" or "16-bit"
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: item.quality!.startsWith('24')
|
||||
? colorScheme.onTertiary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Play button overlay
|
||||
if (fileExists)
|
||||
Positioned(
|
||||
|
|
@ -677,11 +702,38 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
dateStr,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
dateStr,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
// Quality badge
|
||||
if (item.quality != null && item.quality!.contains('bit')) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: item.quality!.startsWith('24')
|
||||
? colorScheme.tertiaryContainer
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
item.quality!,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: item.quality!.startsWith('24')
|
||||
? colorScheme.onTertiaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -99,23 +99,6 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||
),
|
||||
),
|
||||
|
||||
// Lyrics section
|
||||
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Lyrics')),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.translate,
|
||||
title: 'Convert Japanese to Romaji',
|
||||
subtitle: 'Auto-convert Hiragana/Katakana lyrics',
|
||||
value: settings.convertLyricsToRomaji,
|
||||
onChanged: (v) => ref.read(settingsProvider.notifier).setConvertLyricsToRomaji(v),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// App section
|
||||
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'App')),
|
||||
SliverToBoxAdapter(
|
||||
|
|
|
|||
|
|
@ -392,7 +392,19 @@ class SettingsScreen extends ConsumerWidget {
|
|||
title: const Text('Select Quality'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Disclaimer
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Text(
|
||||
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildQualityOption(context, ref, 'LOSSLESS', 'FLAC (Lossless)', '16-bit / 44.1kHz', current, colorScheme),
|
||||
_buildQualityOption(context, ref, 'HI_RES', 'Hi-Res FLAC', '24-bit / up to 192kHz', current, colorScheme),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -389,7 +389,19 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAli
|
|||
title: const Text('Select Quality'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Disclaimer
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Text(
|
||||
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildQualityOption(context, ref, 'LOSSLESS', 'FLAC (Lossless)', '16-bit / 44.1kHz', current, colorScheme),
|
||||
_buildQualityOption(context, ref, 'HI_RES', 'Hi-Res FLAC', '24-bit / up to 96kHz', current, colorScheme),
|
||||
_buildQualityOption(context, ref, 'HI_RES_LOSSLESS', 'Hi-Res FLAC Max', '24-bit / up to 192kHz', current, colorScheme),
|
||||
|
|
|
|||
|
|
@ -47,6 +47,11 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||
_fileExists = exists;
|
||||
_fileSize = size;
|
||||
});
|
||||
|
||||
// Auto-load lyrics if file exists (embedded lyrics are instant)
|
||||
if (exists) {
|
||||
_fetchLyrics();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -359,22 +364,38 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||
Future<void> _openSpotifyUrl(BuildContext context) async {
|
||||
if (item.spotifyId == null) return;
|
||||
|
||||
final url = 'https://open.spotify.com/track/${item.spotifyId}';
|
||||
final webUrl = 'https://open.spotify.com/track/${item.spotifyId}';
|
||||
final spotifyUri = Uri.parse('spotify:track:${item.spotifyId}');
|
||||
|
||||
try {
|
||||
// Try to open in Spotify app first, fallback to browser
|
||||
final uri = Uri.parse('spotify:track:${item.spotifyId}');
|
||||
// ignore: deprecated_member_use
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri);
|
||||
} else {
|
||||
await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
|
||||
// Try to open in Spotify app first using URI scheme
|
||||
final launched = await launchUrl(
|
||||
spotifyUri,
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
|
||||
if (!launched) {
|
||||
// Fallback to web URL which will redirect to app if installed
|
||||
await launchUrl(
|
||||
Uri.parse(webUrl),
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
_copyToClipboard(context, url);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Spotify URL copied to clipboard')),
|
||||
// If URI scheme fails, try web URL
|
||||
try {
|
||||
await launchUrl(
|
||||
Uri.parse(webUrl),
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
} catch (_) {
|
||||
// Last resort: copy to clipboard
|
||||
if (context.mounted) {
|
||||
_copyToClipboard(context, webUrl);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Spotify URL copied to clipboard')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -392,6 +413,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||
_MetadataItem('Disc number', item.discNumber.toString()),
|
||||
if (item.duration != null)
|
||||
_MetadataItem('Duration', _formatDuration(item.duration!)),
|
||||
if (item.quality != null && item.quality!.contains('bit'))
|
||||
_MetadataItem('Audio quality', item.quality!),
|
||||
if (item.releaseDate != null && item.releaseDate!.isNotEmpty)
|
||||
_MetadataItem('Release date', item.releaseDate!),
|
||||
if (item.isrc != null && item.isrc!.isNotEmpty)
|
||||
|
|
@ -740,6 +763,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||
item.spotifyId ?? '',
|
||||
item.trackName,
|
||||
item.artistName,
|
||||
filePath: _fileExists ? item.filePath : null, // Try embedded lyrics first
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
|
|
|
|||
|
|
@ -60,7 +60,6 @@ class PlatformBridge {
|
|||
String quality = 'LOSSLESS',
|
||||
bool embedLyrics = true,
|
||||
bool embedMaxQualityCover = true,
|
||||
bool convertLyricsToRomaji = false,
|
||||
int trackNumber = 1,
|
||||
int discNumber = 1,
|
||||
int totalTracks = 1,
|
||||
|
|
@ -81,7 +80,6 @@ class PlatformBridge {
|
|||
'quality': quality,
|
||||
'embed_lyrics': embedLyrics,
|
||||
'embed_max_quality_cover': embedMaxQualityCover,
|
||||
'convert_lyrics_to_romaji': convertLyricsToRomaji,
|
||||
'track_number': trackNumber,
|
||||
'disc_number': discNumber,
|
||||
'total_tracks': totalTracks,
|
||||
|
|
@ -107,7 +105,6 @@ class PlatformBridge {
|
|||
String quality = 'LOSSLESS',
|
||||
bool embedLyrics = true,
|
||||
bool embedMaxQualityCover = true,
|
||||
bool convertLyricsToRomaji = false,
|
||||
int trackNumber = 1,
|
||||
int discNumber = 1,
|
||||
int totalTracks = 1,
|
||||
|
|
@ -129,7 +126,6 @@ class PlatformBridge {
|
|||
'quality': quality,
|
||||
'embed_lyrics': embedLyrics,
|
||||
'embed_max_quality_cover': embedMaxQualityCover,
|
||||
'convert_lyrics_to_romaji': convertLyricsToRomaji,
|
||||
'track_number': trackNumber,
|
||||
'disc_number': discNumber,
|
||||
'total_tracks': totalTracks,
|
||||
|
|
@ -214,15 +210,18 @@ class PlatformBridge {
|
|||
}
|
||||
|
||||
/// Get lyrics in LRC format
|
||||
/// First tries to extract from embedded file, then falls back to internet
|
||||
static Future<String> getLyricsLRC(
|
||||
String spotifyId,
|
||||
String trackName,
|
||||
String artistName,
|
||||
) async {
|
||||
String artistName, {
|
||||
String? filePath,
|
||||
}) async {
|
||||
final result = await _channel.invokeMethod('getLyricsLRC', {
|
||||
'spotify_id': spotifyId,
|
||||
'track_name': trackName,
|
||||
'artist_name': artistName,
|
||||
'file_path': filePath ?? '',
|
||||
});
|
||||
return result as String;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
name: spotiflac_android
|
||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||
publish_to: 'none'
|
||||
version: 2.0.1+31
|
||||
version: 2.0.2+32
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.0
|
||||
|
|
|
|||
Loading…
Reference in a new issue