mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-04 11:48:00 +02:00
Compare commits
110 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 107d9ca007 | |||
| 4633c7253a | |||
| 8ace180fa8 | |||
| b9c3f2f0dd | |||
| 81b0eede8c | |||
| eb0cdbeba8 | |||
| ee212a0e48 | |||
| 2073516666 | |||
| 9d479b61d6 | |||
| e207ef89d5 | |||
| 1261da2e5b | |||
| 0c917bc41e | |||
| f525d6c7e6 | |||
| ed7c67a622 | |||
| 99281df5fb | |||
| 24c2fd6a15 | |||
| ec3fe34dc0 | |||
| 56f36da5f9 | |||
| 9bbd774175 | |||
| 020ac32ee6 | |||
| 67a72210ac | |||
| 020f41fd1e | |||
| 820eb8cc32 | |||
| 47fa5c2009 | |||
| 9b0c929423 | |||
| 93105a45fe | |||
| d8b2f4d367 | |||
| f1478bb2ca | |||
| 8b3c377688 | |||
| 8c98b02dca | |||
| 3743e35e8a | |||
| 05a02de4a9 | |||
| c28378cbb5 | |||
| b2bef63b6b | |||
| 6513e14b21 | |||
| fd53755ad6 | |||
| 1dbacb3027 | |||
| 910d9a7662 | |||
| 09bd8c6b21 | |||
| 908d108858 | |||
| 3135993cf4 | |||
| 7a315b5fd4 | |||
| 4bd6dcc3d7 | |||
| 3f7fa19cdf | |||
| fc9a2ddc2a | |||
| c49e5adc52 | |||
| 0fedd446ca | |||
| 0c7b8a68d9 | |||
| 6dd6accbcc | |||
| ca67f7f79d | |||
| 1aa12c5857 | |||
| ff121dfeb8 | |||
| c3aa6a441b | |||
| 496d32e35b | |||
| 291fa58757 | |||
| eddbc2f986 | |||
| 81b8281d2c | |||
| 57f87d9a4c | |||
| c9d0c57d86 | |||
| 54ab5a9243 | |||
| 17b6b27cd7 | |||
| ed131ca1fd | |||
| 190d65cdee | |||
| dbf2e337f0 | |||
| 12e76bed4f | |||
| e00db80dae | |||
| 5de0aa8145 | |||
| 91ffb25027 | |||
| 6bcbdfedf0 | |||
| ccb8f98df5 | |||
| 22f52f4af2 | |||
| ceaaff8c9b | |||
| a318495046 | |||
| 8ffc6d3821 | |||
| 2036e46da0 | |||
| b82000e87c | |||
| 144906fd8f | |||
| 8a109e9013 | |||
| ba05f6b470 | |||
| 2f80ae7e84 | |||
| e248fef130 | |||
| 174724ddd3 | |||
| 730945d892 | |||
| 4abdce8c58 | |||
| 0d98ada479 | |||
| 5d4fc10ab7 | |||
| e37dfeb080 | |||
| eddae2a9dd | |||
| 6bd7eec615 | |||
| b240e91290 | |||
| 4e0149df29 | |||
| 065872e686 | |||
| 7ab0f5b7c8 | |||
| fd31682242 | |||
| 56c8b62fcf | |||
| c3f879346a | |||
| 6da65ed033 | |||
| 553c6b6c4a | |||
| a32487ad88 | |||
| bd4946db37 | |||
| 69f143dd9d | |||
| 15408bfa1c | |||
| edc715021d | |||
| 392472b027 | |||
| 69741fa47c | |||
| 484720bcda | |||
| f3cc51fb06 | |||
| 452ea7084a | |||
| bba059fc44 | |||
| 3f75cace2b |
@@ -1,5 +1,26 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [3.3.5] - 2026-02-01
|
||||||
|
|
||||||
|
Same as 3.3.1 but fixes crash issues caused by FFmpeg.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Export Failed Downloads**: Export failed downloads to TXT file for easy lookup on other platforms
|
||||||
|
- **Auto-Export Setting**: Option to automatically export failed downloads when queue finishes
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **FFmpeg Crash**: Fixed crash issues during M4A to MP3/Opus conversion
|
||||||
|
- **Service Selection Ignored**: Fixed bug where selecting Qobuz/Amazon from service picker was ignored and always used Tidal instead
|
||||||
|
- **iOS iCloud Drive Permission Error**: Block iCloud Drive folder selection on iOS (Go backend cannot access iCloud due to sandboxing)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Amazon Fallback Only**: Amazon Music is now grayed out in service picker and can only be used as fallback provider
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [3.3.1] - 2026-02-01
|
## [3.3.1] - 2026-02-01
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
Vendored
+3
@@ -28,6 +28,9 @@
|
|||||||
# FFmpeg Kit
|
# FFmpeg Kit
|
||||||
-keep class com.arthenica.ffmpegkit.** { *; }
|
-keep class com.arthenica.ffmpegkit.** { *; }
|
||||||
-keep class com.arthenica.smartexception.** { *; }
|
-keep class com.arthenica.smartexception.** { *; }
|
||||||
|
# FFmpeg Kit (new fork package)
|
||||||
|
-keep class com.antonkarpenko.ffmpegkit.** { *; }
|
||||||
|
-keep class com.antonkarpenko.smartexception.** { *; }
|
||||||
|
|
||||||
# Apache Tika (if used by FFmpeg)
|
# Apache Tika (if used by FFmpeg)
|
||||||
-dontwarn org.apache.tika.**
|
-dontwarn org.apache.tika.**
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import io.flutter.embedding.android.FlutterActivity
|
|||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
import io.flutter.embedding.engine.FlutterShellArgs
|
import io.flutter.embedding.engine.FlutterShellArgs
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
import io.flutter.plugins.GeneratedPluginRegistrant
|
|
||||||
import gobackend.Gobackend
|
import gobackend.Gobackend
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -158,7 +157,6 @@ class MainActivity: FlutterActivity() {
|
|||||||
|
|
||||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
super.configureFlutterEngine(flutterEngine)
|
super.configureFlutterEngine(flutterEngine)
|
||||||
GeneratedPluginRegistrant.registerWith(flutterEngine)
|
|
||||||
|
|
||||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
|
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
|||||||
+28
-16
@@ -54,10 +54,7 @@ func NewAmazonDownloader() *AmazonDownloader {
|
|||||||
return globalAmazonDownloader
|
return globalAmazonDownloader
|
||||||
}
|
}
|
||||||
|
|
||||||
// downloadFromAfkarXYZ downloads a track using AfkarXYZ API
|
|
||||||
// Returns: downloadURL, fileName, error
|
|
||||||
func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, string, error) {
|
func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, string, error) {
|
||||||
// AfkarXYZ API endpoint
|
|
||||||
apiURL := "https://amazon.afkarxyz.fun/convert?url=" + url.QueryEscape(amazonURL)
|
apiURL := "https://amazon.afkarxyz.fun/convert?url=" + url.QueryEscape(amazonURL)
|
||||||
|
|
||||||
GoLog("[Amazon] Fetching from AfkarXYZ API...\n")
|
GoLog("[Amazon] Fetching from AfkarXYZ API...\n")
|
||||||
@@ -98,7 +95,6 @@ func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, strin
|
|||||||
fileName = "track.flac"
|
fileName = "track.flac"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sanitize filename
|
|
||||||
reg := regexp.MustCompile(`[<>:"/\\|?*]`)
|
reg := regexp.MustCompile(`[<>:"/\\|?*]`)
|
||||||
fileName = reg.ReplaceAllString(fileName, "")
|
fileName = reg.ReplaceAllString(fileName, "")
|
||||||
|
|
||||||
@@ -110,7 +106,6 @@ func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, strin
|
|||||||
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Initialize item progress (required for all downloads)
|
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
StartItemProgress(itemID)
|
StartItemProgress(itemID)
|
||||||
defer CompleteItemProgress(itemID)
|
defer CompleteItemProgress(itemID)
|
||||||
@@ -162,7 +157,6 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
|
|||||||
written, err = io.Copy(bufWriter, resp.Body)
|
written, err = io.Copy(bufWriter, resp.Body)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flush buffer before checking for errors
|
|
||||||
flushErr := bufWriter.Flush()
|
flushErr := bufWriter.Flush()
|
||||||
closeErr := out.Close()
|
closeErr := out.Close()
|
||||||
|
|
||||||
@@ -182,7 +176,6 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
|
|||||||
return fmt.Errorf("failed to close file: %w", closeErr)
|
return fmt.Errorf("failed to close file: %w", closeErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify file size if Content-Length was provided
|
|
||||||
if expectedSize > 0 && written != expectedSize {
|
if expectedSize > 0 && written != expectedSize {
|
||||||
os.Remove(outputPath)
|
os.Remove(outputPath)
|
||||||
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
||||||
@@ -206,7 +199,6 @@ type AmazonDownloadResult struct {
|
|||||||
ISRC string
|
ISRC string
|
||||||
}
|
}
|
||||||
|
|
||||||
// downloadFromAmazon uses AfkarXYZ API to download from Amazon Music
|
|
||||||
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||||
downloader := NewAmazonDownloader()
|
downloader := NewAmazonDownloader()
|
||||||
|
|
||||||
@@ -299,6 +291,10 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
existingMeta, metaErr := ReadMetadata(outputPath)
|
existingMeta, metaErr := ReadMetadata(outputPath)
|
||||||
actualTrackNum := req.TrackNumber
|
actualTrackNum := req.TrackNumber
|
||||||
actualDiscNum := req.DiscNumber
|
actualDiscNum := req.DiscNumber
|
||||||
|
actualDate := req.ReleaseDate
|
||||||
|
actualAlbum := req.AlbumName
|
||||||
|
actualTitle := req.TrackName
|
||||||
|
actualArtist := req.ArtistName
|
||||||
|
|
||||||
if metaErr == nil && existingMeta != nil {
|
if metaErr == nil && existingMeta != nil {
|
||||||
if existingMeta.TrackNumber > 0 && (req.TrackNumber == 0 || req.TrackNumber == 1) {
|
if existingMeta.TrackNumber > 0 && (req.TrackNumber == 0 || req.TrackNumber == 1) {
|
||||||
@@ -309,15 +305,24 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
actualDiscNum = existingMeta.DiscNumber
|
actualDiscNum = existingMeta.DiscNumber
|
||||||
GoLog("[Amazon] Using disc number from file: %d (request had: %d)\n", actualDiscNum, req.DiscNumber)
|
GoLog("[Amazon] Using disc number from file: %d (request had: %d)\n", actualDiscNum, req.DiscNumber)
|
||||||
}
|
}
|
||||||
|
if existingMeta.Date != "" && req.ReleaseDate == "" {
|
||||||
|
actualDate = existingMeta.Date
|
||||||
|
GoLog("[Amazon] Using release date from file: %s\n", actualDate)
|
||||||
|
}
|
||||||
|
if existingMeta.Album != "" && req.AlbumName == "" {
|
||||||
|
actualAlbum = existingMeta.Album
|
||||||
|
GoLog("[Amazon] Using album from file: %s\n", actualAlbum)
|
||||||
|
}
|
||||||
|
GoLog("[Amazon] Existing metadata - Title: %s, Artist: %s, Album: %s, Date: %s\n",
|
||||||
|
existingMeta.Title, existingMeta.Artist, existingMeta.Album, existingMeta.Date)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Embed metadata using Spotify data
|
|
||||||
metadata := Metadata{
|
metadata := Metadata{
|
||||||
Title: req.TrackName,
|
Title: actualTitle,
|
||||||
Artist: req.ArtistName,
|
Artist: actualArtist,
|
||||||
Album: req.AlbumName,
|
Album: actualAlbum,
|
||||||
AlbumArtist: req.AlbumArtist,
|
AlbumArtist: req.AlbumArtist,
|
||||||
Date: req.ReleaseDate,
|
Date: actualDate,
|
||||||
TrackNumber: actualTrackNum,
|
TrackNumber: actualTrackNum,
|
||||||
TotalTracks: req.TotalTracks,
|
TotalTracks: req.TotalTracks,
|
||||||
DiscNumber: actualDiscNum,
|
DiscNumber: actualDiscNum,
|
||||||
@@ -327,11 +332,18 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
Copyright: req.Copyright,
|
Copyright: req.Copyright,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use cover data from parallel fetch
|
|
||||||
var coverData []byte
|
var coverData []byte
|
||||||
if parallelResult != nil && parallelResult.CoverData != nil {
|
if parallelResult != nil && parallelResult.CoverData != nil && len(parallelResult.CoverData) > 0 {
|
||||||
coverData = parallelResult.CoverData
|
coverData = parallelResult.CoverData
|
||||||
GoLog("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
GoLog("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
||||||
|
} else {
|
||||||
|
existingCover, coverErr := ExtractCoverArt(outputPath)
|
||||||
|
if coverErr == nil && len(existingCover) > 0 {
|
||||||
|
coverData = existingCover
|
||||||
|
GoLog("[Amazon] Using existing cover from Amazon file (%d bytes)\n", len(coverData))
|
||||||
|
} else {
|
||||||
|
GoLog("[Amazon] No cover available (parallel fetch failed and no existing cover)\n")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
||||||
@@ -341,7 +353,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||||
lyricsMode := req.LyricsMode
|
lyricsMode := req.LyricsMode
|
||||||
if lyricsMode == "" {
|
if lyricsMode == "" {
|
||||||
lyricsMode = "embed" // default
|
lyricsMode = "embed"
|
||||||
}
|
}
|
||||||
|
|
||||||
if lyricsMode == "external" || lyricsMode == "both" {
|
if lyricsMode == "external" || lyricsMode == "both" {
|
||||||
|
|||||||
+8
-38
@@ -55,7 +55,7 @@ func GetDeezerClient() *DeezerClient {
|
|||||||
type deezerTrack struct {
|
type deezerTrack struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Duration int `json:"duration"` // in seconds
|
Duration int `json:"duration"`
|
||||||
TrackPosition int `json:"track_position"`
|
TrackPosition int `json:"track_position"`
|
||||||
DiskNumber int `json:"disk_number"`
|
DiskNumber int `json:"disk_number"`
|
||||||
ISRC string `json:"isrc"`
|
ISRC string `json:"isrc"`
|
||||||
@@ -121,7 +121,7 @@ func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
|
|||||||
AlbumArtist: track.Artist.Name,
|
AlbumArtist: track.Artist.Name,
|
||||||
DurationMS: track.Duration * 1000,
|
DurationMS: track.Duration * 1000,
|
||||||
Images: albumImage,
|
Images: albumImage,
|
||||||
ReleaseDate: releaseDate, // Added this
|
ReleaseDate: releaseDate,
|
||||||
TrackNumber: track.TrackPosition,
|
TrackNumber: track.TrackPosition,
|
||||||
DiscNumber: track.DiskNumber,
|
DiscNumber: track.DiskNumber,
|
||||||
ExternalURL: track.Link,
|
ExternalURL: track.Link,
|
||||||
@@ -182,15 +182,12 @@ type deezerPlaylistFull struct {
|
|||||||
} `json:"tracks"`
|
} `json:"tracks"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE: ISRC is NOT fetched during search for performance - use GetTrackISRC when needed for download
|
|
||||||
// filter can be: "" (all), "track", "artist", "album", "playlist"
|
|
||||||
func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int, filter string) (*SearchAllResult, error) {
|
func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int, filter string) (*SearchAllResult, error) {
|
||||||
GoLog("[Deezer] SearchAll: query=%q, trackLimit=%d, artistLimit=%d, filter=%q\n", query, trackLimit, artistLimit, filter)
|
GoLog("[Deezer] SearchAll: query=%q, trackLimit=%d, artistLimit=%d, filter=%q\n", query, trackLimit, artistLimit, filter)
|
||||||
|
|
||||||
albumLimit := 5 // Same as artistLimit for consistency
|
albumLimit := 5
|
||||||
playlistLimit := 5
|
playlistLimit := 5
|
||||||
|
|
||||||
// When filter is specified, increase limits for that type only
|
|
||||||
if filter != "" {
|
if filter != "" {
|
||||||
switch filter {
|
switch filter {
|
||||||
case "track":
|
case "track":
|
||||||
@@ -233,7 +230,6 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
|
|||||||
Playlists: make([]SearchPlaylistResult, 0, playlistLimit),
|
Playlists: make([]SearchPlaylistResult, 0, playlistLimit),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search tracks - NO ISRC fetch for performance
|
|
||||||
if trackLimit > 0 {
|
if trackLimit > 0 {
|
||||||
trackURL := fmt.Sprintf("%s/track?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), trackLimit)
|
trackURL := fmt.Sprintf("%s/track?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), trackLimit)
|
||||||
GoLog("[Deezer] Fetching tracks from: %s\n", trackURL)
|
GoLog("[Deezer] Fetching tracks from: %s\n", trackURL)
|
||||||
@@ -263,7 +259,6 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search artists
|
|
||||||
if artistLimit > 0 {
|
if artistLimit > 0 {
|
||||||
artistURL := fmt.Sprintf("%s/artist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), artistLimit)
|
artistURL := fmt.Sprintf("%s/artist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), artistLimit)
|
||||||
GoLog("[Deezer] Fetching artists from: %s\n", artistURL)
|
GoLog("[Deezer] Fetching artists from: %s\n", artistURL)
|
||||||
@@ -296,7 +291,6 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search albums
|
|
||||||
if albumLimit > 0 {
|
if albumLimit > 0 {
|
||||||
albumURL := fmt.Sprintf("%s/album?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), albumLimit)
|
albumURL := fmt.Sprintf("%s/album?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), albumLimit)
|
||||||
GoLog("[Deezer] Fetching albums from: %s\n", albumURL)
|
GoLog("[Deezer] Fetching albums from: %s\n", albumURL)
|
||||||
@@ -358,7 +352,6 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search playlists
|
|
||||||
if playlistLimit > 0 {
|
if playlistLimit > 0 {
|
||||||
playlistURL := fmt.Sprintf("%s/playlist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), playlistLimit)
|
playlistURL := fmt.Sprintf("%s/playlist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), playlistLimit)
|
||||||
GoLog("[Deezer] Fetching playlists from: %s\n", playlistURL)
|
GoLog("[Deezer] Fetching playlists from: %s\n", playlistURL)
|
||||||
@@ -425,7 +418,6 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTrack fetches a single track by Deezer ID
|
|
||||||
func (c *DeezerClient) GetTrack(ctx context.Context, trackID string) (*TrackResponse, error) {
|
func (c *DeezerClient) GetTrack(ctx context.Context, trackID string) (*TrackResponse, error) {
|
||||||
trackURL := fmt.Sprintf(deezerTrackURL, trackID)
|
trackURL := fmt.Sprintf(deezerTrackURL, trackID)
|
||||||
|
|
||||||
@@ -439,7 +431,6 @@ func (c *DeezerClient) GetTrack(ctx context.Context, trackID string) (*TrackResp
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ISRC is fetched in parallel for better performance
|
|
||||||
func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResponsePayload, error) {
|
func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResponsePayload, error) {
|
||||||
c.cacheMu.RLock()
|
c.cacheMu.RLock()
|
||||||
if entry, ok := c.albumCache[albumID]; ok && !entry.isExpired() {
|
if entry, ok := c.albumCache[albumID]; ok && !entry.isExpired() {
|
||||||
@@ -465,7 +456,6 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
|||||||
artistName = strings.Join(names, ", ")
|
artistName = strings.Join(names, ", ")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract genres as comma-separated string
|
|
||||||
var genres []string
|
var genres []string
|
||||||
for _, g := range album.Genres.Data {
|
for _, g := range album.Genres.Data {
|
||||||
if g.Name != "" {
|
if g.Name != "" {
|
||||||
@@ -481,14 +471,12 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
|||||||
Artists: artistName,
|
Artists: artistName,
|
||||||
ArtistId: fmt.Sprintf("deezer:%d", album.Artist.ID),
|
ArtistId: fmt.Sprintf("deezer:%d", album.Artist.ID),
|
||||||
Images: albumImage,
|
Images: albumImage,
|
||||||
Genre: genreStr, // From Deezer album
|
Genre: genreStr,
|
||||||
Label: album.Label, // From Deezer album
|
Label: album.Label,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch all tracks with pagination (Deezer default limit is 25)
|
|
||||||
allTracks := album.Tracks.Data
|
allTracks := album.Tracks.Data
|
||||||
|
|
||||||
// If album has more tracks than returned, fetch remaining pages
|
|
||||||
if album.NbTracks > len(allTracks) {
|
if album.NbTracks > len(allTracks) {
|
||||||
GoLog("[Deezer] Album has %d tracks but only got %d, fetching remaining...", album.NbTracks, len(allTracks))
|
GoLog("[Deezer] Album has %d tracks but only got %d, fetching remaining...", album.NbTracks, len(allTracks))
|
||||||
|
|
||||||
@@ -523,7 +511,6 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
|||||||
isrcMap := c.fetchISRCsParallel(ctx, allTracks)
|
isrcMap := c.fetchISRCsParallel(ctx, allTracks)
|
||||||
|
|
||||||
tracks := make([]AlbumTrackMetadata, 0, len(allTracks))
|
tracks := make([]AlbumTrackMetadata, 0, len(allTracks))
|
||||||
// Normalize record_type (Deezer uses "compile" instead of "compilation")
|
|
||||||
albumType := album.RecordType
|
albumType := album.RecordType
|
||||||
if albumType == "compile" {
|
if albumType == "compile" {
|
||||||
albumType = "compilation"
|
albumType = "compilation"
|
||||||
@@ -533,7 +520,6 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
|||||||
trackIDStr := fmt.Sprintf("%d", track.ID)
|
trackIDStr := fmt.Sprintf("%d", track.ID)
|
||||||
isrc := isrcMap[trackIDStr]
|
isrc := isrcMap[trackIDStr]
|
||||||
|
|
||||||
// Use track position from API, fallback to index+1 if not provided
|
|
||||||
trackNum := track.TrackPosition
|
trackNum := track.TrackPosition
|
||||||
if trackNum == 0 {
|
if trackNum == 0 {
|
||||||
trackNum = i + 1
|
trackNum = i + 1
|
||||||
@@ -581,7 +567,6 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
|
|||||||
}
|
}
|
||||||
c.cacheMu.RUnlock()
|
c.cacheMu.RUnlock()
|
||||||
|
|
||||||
// Fetch artist info
|
|
||||||
artistURL := fmt.Sprintf(deezerArtistURL, artistID)
|
artistURL := fmt.Sprintf(deezerArtistURL, artistID)
|
||||||
var artist deezerArtistFull
|
var artist deezerArtistFull
|
||||||
if err := c.getJSON(ctx, artistURL, &artist); err != nil {
|
if err := c.getJSON(ctx, artistURL, &artist); err != nil {
|
||||||
@@ -596,7 +581,6 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
|
|||||||
Popularity: 0,
|
Popularity: 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch artist albums
|
|
||||||
albumsURL := fmt.Sprintf("%s/albums?limit=100", fmt.Sprintf(deezerArtistURL, artistID))
|
albumsURL := fmt.Sprintf("%s/albums?limit=100", fmt.Sprintf(deezerArtistURL, artistID))
|
||||||
var albumsResp struct {
|
var albumsResp struct {
|
||||||
Data []struct {
|
Data []struct {
|
||||||
@@ -608,7 +592,7 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
|
|||||||
CoverMedium string `json:"cover_medium"`
|
CoverMedium string `json:"cover_medium"`
|
||||||
CoverBig string `json:"cover_big"`
|
CoverBig string `json:"cover_big"`
|
||||||
CoverXL string `json:"cover_xl"`
|
CoverXL string `json:"cover_xl"`
|
||||||
RecordType string `json:"record_type"` // album, single, ep, compile
|
RecordType string `json:"record_type"`
|
||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -680,10 +664,8 @@ func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*Pla
|
|||||||
info.Owner.Name = playlist.Title
|
info.Owner.Name = playlist.Title
|
||||||
info.Owner.Images = playlistImage
|
info.Owner.Images = playlistImage
|
||||||
|
|
||||||
// Fetch all tracks with pagination (Deezer default limit is 25)
|
|
||||||
allTracks := playlist.Tracks.Data
|
allTracks := playlist.Tracks.Data
|
||||||
|
|
||||||
// If playlist has more tracks than returned, fetch remaining pages
|
|
||||||
if playlist.NbTracks > len(allTracks) {
|
if playlist.NbTracks > len(allTracks) {
|
||||||
GoLog("[Deezer] Playlist has %d tracks but only got %d, fetching remaining...", playlist.NbTracks, len(allTracks))
|
GoLog("[Deezer] Playlist has %d tracks but only got %d, fetching remaining...", playlist.NbTracks, len(allTracks))
|
||||||
|
|
||||||
@@ -789,7 +771,6 @@ func (c *DeezerClient) fetchFullTrack(ctx context.Context, trackID string) (*dee
|
|||||||
return &track, nil
|
return &track, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchISRCsParallel fetches ISRCs for multiple tracks in parallel with caching
|
|
||||||
func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTrack) map[string]string {
|
func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTrack) map[string]string {
|
||||||
result := make(map[string]string, len(tracks))
|
result := make(map[string]string, len(tracks))
|
||||||
var resultMu sync.Mutex
|
var resultMu sync.Mutex
|
||||||
@@ -828,7 +809,6 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use semaphore to limit concurrent requests
|
|
||||||
sem := make(chan struct{}, deezerMaxParallelISRC)
|
sem := make(chan struct{}, deezerMaxParallelISRC)
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
@@ -850,7 +830,6 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store in result and cache
|
|
||||||
resultMu.Lock()
|
resultMu.Lock()
|
||||||
result[trackIDStr] = fullTrack.ISRC
|
result[trackIDStr] = fullTrack.ISRC
|
||||||
resultMu.Unlock()
|
resultMu.Unlock()
|
||||||
@@ -865,7 +844,6 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use this when you need ISRC for download
|
|
||||||
func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string, error) {
|
func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string, error) {
|
||||||
c.cacheMu.RLock()
|
c.cacheMu.RLock()
|
||||||
if isrc, ok := c.isrcCache[trackID]; ok {
|
if isrc, ok := c.isrcCache[trackID]; ok {
|
||||||
@@ -926,11 +904,10 @@ func (c *DeezerClient) getBestAlbumImage(album deezerAlbumFull) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AlbumExtendedMetadata struct {
|
type AlbumExtendedMetadata struct {
|
||||||
Genre string // Comma-separated list of genres
|
Genre string
|
||||||
Label string // Record label name
|
Label string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Uses the album ID from a track to fetch extended metadata
|
|
||||||
func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID string) (*AlbumExtendedMetadata, error) {
|
func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID string) (*AlbumExtendedMetadata, error) {
|
||||||
if albumID == "" {
|
if albumID == "" {
|
||||||
return nil, fmt.Errorf("empty album ID")
|
return nil, fmt.Errorf("empty album ID")
|
||||||
@@ -975,7 +952,6 @@ func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID str
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTrackAlbumID fetches the album ID for a Deezer track
|
|
||||||
func (c *DeezerClient) GetTrackAlbumID(ctx context.Context, trackID string) (string, error) {
|
func (c *DeezerClient) GetTrackAlbumID(ctx context.Context, trackID string) (string, error) {
|
||||||
trackURL := fmt.Sprintf(deezerTrackURL, trackID)
|
trackURL := fmt.Sprintf(deezerTrackURL, trackID)
|
||||||
|
|
||||||
@@ -987,7 +963,6 @@ func (c *DeezerClient) GetTrackAlbumID(ctx context.Context, trackID string) (str
|
|||||||
return fmt.Sprintf("%d", track.Album.ID), nil
|
return fmt.Sprintf("%d", track.Album.ID), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// This is a convenience function that first gets the album ID, then fetches album metadata
|
|
||||||
func (c *DeezerClient) GetExtendedMetadataByTrackID(ctx context.Context, trackID string) (*AlbumExtendedMetadata, error) {
|
func (c *DeezerClient) GetExtendedMetadataByTrackID(ctx context.Context, trackID string) (*AlbumExtendedMetadata, error) {
|
||||||
albumID, err := c.GetTrackAlbumID(ctx, trackID)
|
albumID, err := c.GetTrackAlbumID(ctx, trackID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -997,26 +972,22 @@ func (c *DeezerClient) GetExtendedMetadataByTrackID(ctx context.Context, trackID
|
|||||||
return c.GetAlbumExtendedMetadata(ctx, albumID)
|
return c.GetAlbumExtendedMetadata(ctx, albumID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetExtendedMetadataByISRC searches for a track by ISRC and fetches extended metadata (genre, label)
|
|
||||||
func (c *DeezerClient) GetExtendedMetadataByISRC(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) {
|
func (c *DeezerClient) GetExtendedMetadataByISRC(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) {
|
||||||
if isrc == "" {
|
if isrc == "" {
|
||||||
return nil, fmt.Errorf("empty ISRC")
|
return nil, fmt.Errorf("empty ISRC")
|
||||||
}
|
}
|
||||||
|
|
||||||
// First, search for track by ISRC
|
|
||||||
track, err := c.SearchByISRC(ctx, isrc)
|
track, err := c.SearchByISRC(ctx, isrc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to find track by ISRC: %w", err)
|
return nil, fmt.Errorf("failed to find track by ISRC: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SpotifyID contains "deezer:123" format, extract the ID
|
|
||||||
deezerID := strings.TrimPrefix(track.SpotifyID, "deezer:")
|
deezerID := strings.TrimPrefix(track.SpotifyID, "deezer:")
|
||||||
|
|
||||||
if deezerID == "" {
|
if deezerID == "" {
|
||||||
return nil, fmt.Errorf("track found but no Deezer ID")
|
return nil, fmt.Errorf("track found but no Deezer ID")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then fetch extended metadata using the Deezer track ID
|
|
||||||
return c.GetExtendedMetadataByTrackID(ctx, deezerID)
|
return c.GetExtendedMetadataByTrackID(ctx, deezerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1046,7 +1017,6 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa
|
|||||||
return json.Unmarshal(body, dst)
|
return json.Unmarshal(body, dst)
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseDeezerURL is internal function, returns type and ID
|
|
||||||
func parseDeezerURL(input string) (string, string, error) {
|
func parseDeezerURL(input string) (string, string, error) {
|
||||||
trimmed := strings.TrimSpace(input)
|
trimmed := strings.TrimSpace(input)
|
||||||
if trimmed == "" {
|
if trimmed == "" {
|
||||||
|
|||||||
+1
-18
@@ -10,7 +10,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ISRCIndex holds a cached map of ISRC -> file path for fast duplicate checking
|
|
||||||
type ISRCIndex struct {
|
type ISRCIndex struct {
|
||||||
index map[string]string // ISRC (uppercase) -> file path
|
index map[string]string // ISRC (uppercase) -> file path
|
||||||
outputDir string
|
outputDir string
|
||||||
@@ -25,8 +24,6 @@ var (
|
|||||||
isrcIndexTTL = 5 * time.Minute
|
isrcIndexTTL = 5 * time.Minute
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetISRCIndex returns or builds an ISRC index for the given directory
|
|
||||||
// Uses per-directory mutex to prevent concurrent builds (race condition fix)
|
|
||||||
func GetISRCIndex(outputDir string) *ISRCIndex {
|
func GetISRCIndex(outputDir string) *ISRCIndex {
|
||||||
// Fast path: check cache first
|
// Fast path: check cache first
|
||||||
isrcIndexCacheMu.RLock()
|
isrcIndexCacheMu.RLock()
|
||||||
@@ -56,7 +53,6 @@ func GetISRCIndex(outputDir string) *ISRCIndex {
|
|||||||
return buildISRCIndex(outputDir)
|
return buildISRCIndex(outputDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildISRCIndex scans a directory and builds a map of ISRC -> file path
|
|
||||||
func buildISRCIndex(outputDir string) *ISRCIndex {
|
func buildISRCIndex(outputDir string) *ISRCIndex {
|
||||||
idx := &ISRCIndex{
|
idx := &ISRCIndex{
|
||||||
index: make(map[string]string),
|
index: make(map[string]string),
|
||||||
@@ -91,7 +87,7 @@ func buildISRCIndex(outputDir string) *ISRCIndex {
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
fmt.Printf("[ISRCIndex] Built index for %s: %d files in %v\n",
|
fmt.Printf("[ISRCIndex] Built index for %s: %d files in %v\n",
|
||||||
outputDir, fileCount, time.Since(startTime).Round(time.Millisecond))
|
outputDir, fileCount, time.Since(startTime).Round(time.Millisecond))
|
||||||
|
|
||||||
isrcIndexCacheMu.Lock()
|
isrcIndexCacheMu.Lock()
|
||||||
@@ -113,7 +109,6 @@ func (idx *ISRCIndex) lookup(isrc string) (string, bool) {
|
|||||||
return path, exists
|
return path, exists
|
||||||
}
|
}
|
||||||
|
|
||||||
// remove deletes an ISRC entry from the index (internal use)
|
|
||||||
func (idx *ISRCIndex) remove(isrc string) {
|
func (idx *ISRCIndex) remove(isrc string) {
|
||||||
if isrc == "" {
|
if isrc == "" {
|
||||||
return
|
return
|
||||||
@@ -125,14 +120,11 @@ func (idx *ISRCIndex) remove(isrc string) {
|
|||||||
delete(idx.index, strings.ToUpper(isrc))
|
delete(idx.index, strings.ToUpper(isrc))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lookup checks if an ISRC exists in the index (gomobile compatible)
|
|
||||||
// Returns filepath if found, empty string if not found
|
|
||||||
func (idx *ISRCIndex) Lookup(isrc string) (string, error) {
|
func (idx *ISRCIndex) Lookup(isrc string) (string, error) {
|
||||||
path, _ := idx.lookup(isrc)
|
path, _ := idx.lookup(isrc)
|
||||||
return path, nil
|
return path, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add adds a new ISRC to the index (call after successful download)
|
|
||||||
func (idx *ISRCIndex) Add(isrc, filePath string) {
|
func (idx *ISRCIndex) Add(isrc, filePath string) {
|
||||||
if isrc == "" || filePath == "" {
|
if isrc == "" || filePath == "" {
|
||||||
return
|
return
|
||||||
@@ -144,15 +136,12 @@ func (idx *ISRCIndex) Add(isrc, filePath string) {
|
|||||||
idx.index[strings.ToUpper(isrc)] = filePath
|
idx.index[strings.ToUpper(isrc)] = filePath
|
||||||
}
|
}
|
||||||
|
|
||||||
// InvalidateCache clears the ISRC index cache for a directory
|
|
||||||
func InvalidateISRCCache(outputDir string) {
|
func InvalidateISRCCache(outputDir string) {
|
||||||
isrcIndexCacheMu.Lock()
|
isrcIndexCacheMu.Lock()
|
||||||
delete(isrcIndexCache, outputDir)
|
delete(isrcIndexCache, outputDir)
|
||||||
isrcIndexCacheMu.Unlock()
|
isrcIndexCacheMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkISRCExistsInternal checks if a file with the given ISRC exists (internal use)
|
|
||||||
// Uses ISRC index for fast lookup
|
|
||||||
func checkISRCExistsInternal(outputDir, isrc string) (string, bool) {
|
func checkISRCExistsInternal(outputDir, isrc string) (string, bool) {
|
||||||
if isrc == "" || outputDir == "" {
|
if isrc == "" || outputDir == "" {
|
||||||
return "", false
|
return "", false
|
||||||
@@ -173,13 +162,11 @@ func checkISRCExistsInternal(outputDir, isrc string) (string, bool) {
|
|||||||
return filePath, true
|
return filePath, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckISRCExists is the exported version for gomobile (returns string, error)
|
|
||||||
func CheckISRCExists(outputDir, isrc string) (string, error) {
|
func CheckISRCExists(outputDir, isrc string) (string, error) {
|
||||||
filepath, _ := checkISRCExistsInternal(outputDir, isrc)
|
filepath, _ := checkISRCExistsInternal(outputDir, isrc)
|
||||||
return filepath, nil
|
return filepath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckFileExists checks if a file with the given name exists
|
|
||||||
func CheckFileExists(filePath string) bool {
|
func CheckFileExists(filePath string) bool {
|
||||||
info, err := os.Stat(filePath)
|
info, err := os.Stat(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -188,7 +175,6 @@ func CheckFileExists(filePath string) bool {
|
|||||||
return !info.IsDir() && info.Size() > 0
|
return !info.IsDir() && info.Size() > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// FileExistenceResult represents the result of checking if a file exists
|
|
||||||
type FileExistenceResult struct {
|
type FileExistenceResult struct {
|
||||||
ISRC string `json:"isrc"`
|
ISRC string `json:"isrc"`
|
||||||
Exists bool `json:"exists"`
|
Exists bool `json:"exists"`
|
||||||
@@ -249,8 +235,6 @@ func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error
|
|||||||
return string(resultJSON), nil
|
return string(resultJSON), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// PreBuildISRCIndex pre-builds the ISRC index for a directory
|
|
||||||
// Call this when app starts or when entering album/playlist screen
|
|
||||||
func PreBuildISRCIndex(outputDir string) error {
|
func PreBuildISRCIndex(outputDir string) error {
|
||||||
if outputDir == "" {
|
if outputDir == "" {
|
||||||
return fmt.Errorf("output directory is required")
|
return fmt.Errorf("output directory is required")
|
||||||
@@ -260,7 +244,6 @@ func PreBuildISRCIndex(outputDir string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddToISRCIndex adds a new file to the ISRC index after successful download
|
|
||||||
func AddToISRCIndex(outputDir, isrc, filePath string) {
|
func AddToISRCIndex(outputDir, isrc, filePath string) {
|
||||||
if outputDir == "" || isrc == "" || filePath == "" {
|
if outputDir == "" || isrc == "" || filePath == "" {
|
||||||
return
|
return
|
||||||
|
|||||||
+9
-68
@@ -148,17 +148,16 @@ type DownloadRequest struct {
|
|||||||
LyricsMode string `json:"lyrics_mode,omitempty"`
|
LyricsMode string `json:"lyrics_mode,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DownloadResponse represents the result of a download
|
|
||||||
type DownloadResponse struct {
|
type DownloadResponse struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
FilePath string `json:"file_path,omitempty"`
|
FilePath string `json:"file_path,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
ErrorType string `json:"error_type,omitempty"` // "not_found", "rate_limit", "network", "unknown"
|
ErrorType string `json:"error_type,omitempty"`
|
||||||
AlreadyExists bool `json:"already_exists,omitempty"`
|
AlreadyExists bool `json:"already_exists,omitempty"`
|
||||||
ActualBitDepth int `json:"actual_bit_depth,omitempty"`
|
ActualBitDepth int `json:"actual_bit_depth,omitempty"`
|
||||||
ActualSampleRate int `json:"actual_sample_rate,omitempty"`
|
ActualSampleRate int `json:"actual_sample_rate,omitempty"`
|
||||||
Service string `json:"service,omitempty"` // Actual service used (for fallback)
|
Service string `json:"service,omitempty"`
|
||||||
Title string `json:"title,omitempty"`
|
Title string `json:"title,omitempty"`
|
||||||
Artist string `json:"artist,omitempty"`
|
Artist string `json:"artist,omitempty"`
|
||||||
Album string `json:"album,omitempty"`
|
Album string `json:"album,omitempty"`
|
||||||
@@ -172,6 +171,7 @@ type DownloadResponse struct {
|
|||||||
Label string `json:"label,omitempty"`
|
Label string `json:"label,omitempty"`
|
||||||
Copyright string `json:"copyright,omitempty"`
|
Copyright string `json:"copyright,omitempty"`
|
||||||
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"`
|
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"`
|
||||||
|
LyricsLRC string `json:"lyrics_lrc,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DownloadResult struct {
|
type DownloadResult struct {
|
||||||
@@ -185,6 +185,7 @@ type DownloadResult struct {
|
|||||||
TrackNumber int
|
TrackNumber int
|
||||||
DiscNumber int
|
DiscNumber int
|
||||||
ISRC string
|
ISRC string
|
||||||
|
LyricsLRC string
|
||||||
}
|
}
|
||||||
|
|
||||||
func DownloadTrack(requestJSON string) (string, error) {
|
func DownloadTrack(requestJSON string) (string, error) {
|
||||||
@@ -222,6 +223,7 @@ func DownloadTrack(requestJSON string) (string, error) {
|
|||||||
TrackNumber: tidalResult.TrackNumber,
|
TrackNumber: tidalResult.TrackNumber,
|
||||||
DiscNumber: tidalResult.DiscNumber,
|
DiscNumber: tidalResult.DiscNumber,
|
||||||
ISRC: tidalResult.ISRC,
|
ISRC: tidalResult.ISRC,
|
||||||
|
LyricsLRC: tidalResult.LyricsLRC,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
err = tidalErr
|
err = tidalErr
|
||||||
@@ -317,6 +319,7 @@ func DownloadTrack(requestJSON string) (string, error) {
|
|||||||
TrackNumber: result.TrackNumber,
|
TrackNumber: result.TrackNumber,
|
||||||
DiscNumber: result.DiscNumber,
|
DiscNumber: result.DiscNumber,
|
||||||
ISRC: result.ISRC,
|
ISRC: result.ISRC,
|
||||||
|
LyricsLRC: result.LyricsLRC,
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonBytes, _ := json.Marshal(resp)
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
@@ -380,6 +383,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
TrackNumber: tidalResult.TrackNumber,
|
TrackNumber: tidalResult.TrackNumber,
|
||||||
DiscNumber: tidalResult.DiscNumber,
|
DiscNumber: tidalResult.DiscNumber,
|
||||||
ISRC: tidalResult.ISRC,
|
ISRC: tidalResult.ISRC,
|
||||||
|
LyricsLRC: tidalResult.LyricsLRC,
|
||||||
}
|
}
|
||||||
} else if !errors.Is(tidalErr, ErrDownloadCancelled) {
|
} else if !errors.Is(tidalErr, ErrDownloadCancelled) {
|
||||||
GoLog("[DownloadWithFallback] Tidal error: %v\n", tidalErr)
|
GoLog("[DownloadWithFallback] Tidal error: %v\n", tidalErr)
|
||||||
@@ -452,6 +456,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
TrackNumber: result.TrackNumber,
|
TrackNumber: result.TrackNumber,
|
||||||
DiscNumber: result.DiscNumber,
|
DiscNumber: result.DiscNumber,
|
||||||
ISRC: result.ISRC,
|
ISRC: result.ISRC,
|
||||||
|
LyricsLRC: result.LyricsLRC,
|
||||||
}
|
}
|
||||||
jsonBytes, _ := json.Marshal(resp)
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
@@ -480,6 +485,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
TrackNumber: result.TrackNumber,
|
TrackNumber: result.TrackNumber,
|
||||||
DiscNumber: result.DiscNumber,
|
DiscNumber: result.DiscNumber,
|
||||||
ISRC: result.ISRC,
|
ISRC: result.ISRC,
|
||||||
|
LyricsLRC: result.LyricsLRC,
|
||||||
}
|
}
|
||||||
jsonBytes, _ := json.Marshal(resp)
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
@@ -631,14 +637,11 @@ func FetchLyrics(spotifyID, trackName, artistName string, durationMs int64) (str
|
|||||||
}
|
}
|
||||||
|
|
||||||
func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, durationMs int64) (string, error) {
|
func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, durationMs int64) (string, error) {
|
||||||
// If filePath is provided, ONLY check file - don't fallback to online
|
|
||||||
// This allows Flutter to distinguish between "from file" vs "from online"
|
|
||||||
if filePath != "" {
|
if filePath != "" {
|
||||||
lyrics, err := ExtractLyrics(filePath)
|
lyrics, err := ExtractLyrics(filePath)
|
||||||
if err == nil && lyrics != "" {
|
if err == nil && lyrics != "" {
|
||||||
return lyrics, nil
|
return lyrics, nil
|
||||||
}
|
}
|
||||||
// File has no lyrics - return empty, let Flutter call again without filePath
|
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -649,7 +652,6 @@ func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, dura
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return special marker for instrumental tracks
|
|
||||||
if lyricsData.Instrumental {
|
if lyricsData.Instrumental {
|
||||||
return "[instrumental:true]", nil
|
return "[instrumental:true]", nil
|
||||||
}
|
}
|
||||||
@@ -735,8 +737,6 @@ func SearchDeezerAll(query string, trackLimit, artistLimit int, filter string) (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetDeezerMetadata fetches metadata from Deezer URL or ID
|
// GetDeezerMetadata fetches metadata from Deezer URL or ID
|
||||||
// resourceType: track, album, artist, playlist
|
|
||||||
// resourceID: Deezer ID
|
|
||||||
func GetDeezerMetadata(resourceType, resourceID string) (string, error) {
|
func GetDeezerMetadata(resourceType, resourceID string) (string, error) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -770,7 +770,6 @@ func GetDeezerMetadata(resourceType, resourceID string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseDeezerURLExport parses a Deezer URL and returns type and ID
|
|
||||||
func ParseDeezerURLExport(url string) (string, error) {
|
func ParseDeezerURLExport(url string) (string, error) {
|
||||||
resourceType, resourceID, err := parseDeezerURL(url)
|
resourceType, resourceID, err := parseDeezerURL(url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -790,9 +789,6 @@ func ParseDeezerURLExport(url string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDeezerExtendedMetadata fetches genre and label from Deezer album
|
|
||||||
// trackID: Deezer track ID (will look up album ID from track)
|
|
||||||
// Returns JSON with genre, label fields
|
|
||||||
func GetDeezerExtendedMetadata(trackID string) (string, error) {
|
func GetDeezerExtendedMetadata(trackID string) (string, error) {
|
||||||
if trackID == "" {
|
if trackID == "" {
|
||||||
return "", fmt.Errorf("empty track ID")
|
return "", fmt.Errorf("empty track ID")
|
||||||
@@ -821,7 +817,6 @@ func GetDeezerExtendedMetadata(trackID string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchDeezerByISRC searches for a track by ISRC on Deezer
|
|
||||||
func SearchDeezerByISRC(isrc string) (string, error) {
|
func SearchDeezerByISRC(isrc string) (string, error) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -949,9 +944,6 @@ func CheckAvailabilityFromDeezerID(deezerTrackID string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CheckAvailabilityByPlatformID checks track availability using any platform as source
|
// CheckAvailabilityByPlatformID checks track availability using any platform as source
|
||||||
// platform: "spotify", "deezer", "tidal", "amazonMusic", "appleMusic", "youtube"
|
|
||||||
// entityType: "song" or "album"
|
|
||||||
// entityID: the ID on that platform
|
|
||||||
func CheckAvailabilityByPlatformID(platform, entityType, entityID string) (string, error) {
|
func CheckAvailabilityByPlatformID(platform, entityType, entityID string) (string, error) {
|
||||||
client := NewSongLinkClient()
|
client := NewSongLinkClient()
|
||||||
availability, err := client.CheckAvailabilityByPlatform(platform, entityType, entityID)
|
availability, err := client.CheckAvailabilityByPlatform(platform, entityType, entityID)
|
||||||
@@ -967,19 +959,16 @@ func CheckAvailabilityByPlatformID(platform, entityType, entityID string) (strin
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSpotifyIDFromDeezerTrack converts a Deezer track ID to Spotify track ID
|
|
||||||
func GetSpotifyIDFromDeezerTrack(deezerTrackID string) (string, error) {
|
func GetSpotifyIDFromDeezerTrack(deezerTrackID string) (string, error) {
|
||||||
client := NewSongLinkClient()
|
client := NewSongLinkClient()
|
||||||
return client.GetSpotifyIDFromDeezer(deezerTrackID)
|
return client.GetSpotifyIDFromDeezer(deezerTrackID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTidalURLFromDeezerTrack converts a Deezer track ID to Tidal URL
|
|
||||||
func GetTidalURLFromDeezerTrack(deezerTrackID string) (string, error) {
|
func GetTidalURLFromDeezerTrack(deezerTrackID string) (string, error) {
|
||||||
client := NewSongLinkClient()
|
client := NewSongLinkClient()
|
||||||
return client.GetTidalURLFromDeezer(deezerTrackID)
|
return client.GetTidalURLFromDeezer(deezerTrackID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAmazonURLFromDeezerTrack converts a Deezer track ID to Amazon Music URL
|
|
||||||
func GetAmazonURLFromDeezerTrack(deezerTrackID string) (string, error) {
|
func GetAmazonURLFromDeezerTrack(deezerTrackID string) (string, error) {
|
||||||
client := NewSongLinkClient()
|
client := NewSongLinkClient()
|
||||||
return client.GetAmazonURLFromDeezer(deezerTrackID)
|
return client.GetAmazonURLFromDeezer(deezerTrackID)
|
||||||
@@ -1029,7 +1018,6 @@ func errorResponse(msg string) (string, error) {
|
|||||||
|
|
||||||
// ==================== EXTENSION SYSTEM ====================
|
// ==================== EXTENSION SYSTEM ====================
|
||||||
|
|
||||||
// InitExtensionSystem initializes the extension system with directories
|
|
||||||
func InitExtensionSystem(extensionsDir, dataDir string) error {
|
func InitExtensionSystem(extensionsDir, dataDir string) error {
|
||||||
manager := GetExtensionManager()
|
manager := GetExtensionManager()
|
||||||
if err := manager.SetDirectories(extensionsDir, dataDir); err != nil {
|
if err := manager.SetDirectories(extensionsDir, dataDir); err != nil {
|
||||||
@@ -1044,7 +1032,6 @@ func InitExtensionSystem(extensionsDir, dataDir string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadExtensionsFromDir loads all extensions from a directory
|
|
||||||
func LoadExtensionsFromDir(dirPath string) (string, error) {
|
func LoadExtensionsFromDir(dirPath string) (string, error) {
|
||||||
manager := GetExtensionManager()
|
manager := GetExtensionManager()
|
||||||
loaded, errors := manager.LoadExtensionsFromDirectory(dirPath)
|
loaded, errors := manager.LoadExtensionsFromDirectory(dirPath)
|
||||||
@@ -1066,7 +1053,6 @@ func LoadExtensionsFromDir(dirPath string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadExtensionFromPath loads a single extension from a .spotiflac-ext file
|
|
||||||
func LoadExtensionFromPath(filePath string) (string, error) {
|
func LoadExtensionFromPath(filePath string) (string, error) {
|
||||||
manager := GetExtensionManager()
|
manager := GetExtensionManager()
|
||||||
ext, err := manager.LoadExtensionFromFile(filePath)
|
ext, err := manager.LoadExtensionFromFile(filePath)
|
||||||
@@ -1096,19 +1082,16 @@ func LoadExtensionFromPath(filePath string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnloadExtensionByID unloads an extension
|
|
||||||
func UnloadExtensionByID(extensionID string) error {
|
func UnloadExtensionByID(extensionID string) error {
|
||||||
manager := GetExtensionManager()
|
manager := GetExtensionManager()
|
||||||
return manager.UnloadExtension(extensionID)
|
return manager.UnloadExtension(extensionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveExtensionByID completely removes an extension (unload + delete files)
|
|
||||||
func RemoveExtensionByID(extensionID string) error {
|
func RemoveExtensionByID(extensionID string) error {
|
||||||
manager := GetExtensionManager()
|
manager := GetExtensionManager()
|
||||||
return manager.RemoveExtension(extensionID)
|
return manager.RemoveExtension(extensionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpgradeExtensionFromPath upgrades an existing extension from a new package file
|
|
||||||
func UpgradeExtensionFromPath(filePath string) (string, error) {
|
func UpgradeExtensionFromPath(filePath string) (string, error) {
|
||||||
manager := GetExtensionManager()
|
manager := GetExtensionManager()
|
||||||
ext, err := manager.UpgradeExtension(filePath)
|
ext, err := manager.UpgradeExtension(filePath)
|
||||||
@@ -1137,25 +1120,21 @@ func UpgradeExtensionFromPath(filePath string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckExtensionUpgradeFromPath checks if a package file is an upgrade for an existing extension
|
|
||||||
func CheckExtensionUpgradeFromPath(filePath string) (string, error) {
|
func CheckExtensionUpgradeFromPath(filePath string) (string, error) {
|
||||||
manager := GetExtensionManager()
|
manager := GetExtensionManager()
|
||||||
return manager.CheckExtensionUpgradeJSON(filePath)
|
return manager.CheckExtensionUpgradeJSON(filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetInstalledExtensions returns all installed extensions as JSON
|
|
||||||
func GetInstalledExtensions() (string, error) {
|
func GetInstalledExtensions() (string, error) {
|
||||||
manager := GetExtensionManager()
|
manager := GetExtensionManager()
|
||||||
return manager.GetInstalledExtensionsJSON()
|
return manager.GetInstalledExtensionsJSON()
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetExtensionEnabledByID enables or disables an extension
|
|
||||||
func SetExtensionEnabledByID(extensionID string, enabled bool) error {
|
func SetExtensionEnabledByID(extensionID string, enabled bool) error {
|
||||||
manager := GetExtensionManager()
|
manager := GetExtensionManager()
|
||||||
return manager.SetExtensionEnabled(extensionID, enabled)
|
return manager.SetExtensionEnabled(extensionID, enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetProviderPriorityJSON sets the provider priority order from JSON array
|
|
||||||
func SetProviderPriorityJSON(priorityJSON string) error {
|
func SetProviderPriorityJSON(priorityJSON string) error {
|
||||||
var priority []string
|
var priority []string
|
||||||
if err := json.Unmarshal([]byte(priorityJSON), &priority); err != nil {
|
if err := json.Unmarshal([]byte(priorityJSON), &priority); err != nil {
|
||||||
@@ -1166,7 +1145,6 @@ func SetProviderPriorityJSON(priorityJSON string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetProviderPriorityJSON returns the provider priority order as JSON
|
|
||||||
func GetProviderPriorityJSON() (string, error) {
|
func GetProviderPriorityJSON() (string, error) {
|
||||||
priority := GetProviderPriority()
|
priority := GetProviderPriority()
|
||||||
jsonBytes, err := json.Marshal(priority)
|
jsonBytes, err := json.Marshal(priority)
|
||||||
@@ -1176,7 +1154,6 @@ func GetProviderPriorityJSON() (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetMetadataProviderPriorityJSON sets the metadata provider priority order from JSON array
|
|
||||||
func SetMetadataProviderPriorityJSON(priorityJSON string) error {
|
func SetMetadataProviderPriorityJSON(priorityJSON string) error {
|
||||||
var priority []string
|
var priority []string
|
||||||
if err := json.Unmarshal([]byte(priorityJSON), &priority); err != nil {
|
if err := json.Unmarshal([]byte(priorityJSON), &priority); err != nil {
|
||||||
@@ -1187,7 +1164,6 @@ func SetMetadataProviderPriorityJSON(priorityJSON string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetMetadataProviderPriorityJSON returns the metadata provider priority order as JSON
|
|
||||||
func GetMetadataProviderPriorityJSON() (string, error) {
|
func GetMetadataProviderPriorityJSON() (string, error) {
|
||||||
priority := GetMetadataProviderPriority()
|
priority := GetMetadataProviderPriority()
|
||||||
jsonBytes, err := json.Marshal(priority)
|
jsonBytes, err := json.Marshal(priority)
|
||||||
@@ -1197,7 +1173,6 @@ func GetMetadataProviderPriorityJSON() (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetExtensionSettingsJSON returns settings for an extension as JSON
|
|
||||||
func GetExtensionSettingsJSON(extensionID string) (string, error) {
|
func GetExtensionSettingsJSON(extensionID string) (string, error) {
|
||||||
store := GetExtensionSettingsStore()
|
store := GetExtensionSettingsStore()
|
||||||
settings := store.GetAll(extensionID)
|
settings := store.GetAll(extensionID)
|
||||||
@@ -1210,7 +1185,6 @@ func GetExtensionSettingsJSON(extensionID string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetExtensionSettingsJSON sets settings for an extension from JSON
|
|
||||||
func SetExtensionSettingsJSON(extensionID, settingsJSON string) error {
|
func SetExtensionSettingsJSON(extensionID, settingsJSON string) error {
|
||||||
var settings map[string]interface{}
|
var settings map[string]interface{}
|
||||||
if err := json.Unmarshal([]byte(settingsJSON), &settings); err != nil {
|
if err := json.Unmarshal([]byte(settingsJSON), &settings); err != nil {
|
||||||
@@ -1226,7 +1200,6 @@ func SetExtensionSettingsJSON(extensionID, settingsJSON string) error {
|
|||||||
return manager.InitializeExtension(extensionID, settings)
|
return manager.InitializeExtension(extensionID, settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchTracksWithExtensionsJSON searches all extension metadata providers
|
|
||||||
func SearchTracksWithExtensionsJSON(query string, limit int) (string, error) {
|
func SearchTracksWithExtensionsJSON(query string, limit int) (string, error) {
|
||||||
manager := GetExtensionManager()
|
manager := GetExtensionManager()
|
||||||
tracks, err := manager.SearchTracksWithExtensions(query, limit)
|
tracks, err := manager.SearchTracksWithExtensions(query, limit)
|
||||||
@@ -1242,7 +1215,6 @@ func SearchTracksWithExtensionsJSON(query string, limit int) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DownloadWithExtensionsJSON downloads using extension providers with fallback
|
|
||||||
func DownloadWithExtensionsJSON(requestJSON string) (string, error) {
|
func DownloadWithExtensionsJSON(requestJSON string) (string, error) {
|
||||||
var req DownloadRequest
|
var req DownloadRequest
|
||||||
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
||||||
@@ -1262,14 +1234,11 @@ func DownloadWithExtensionsJSON(requestJSON string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CleanupExtensions unloads all extensions gracefully
|
|
||||||
func CleanupExtensions() {
|
func CleanupExtensions() {
|
||||||
manager := GetExtensionManager()
|
manager := GetExtensionManager()
|
||||||
manager.UnloadAllExtensions()
|
manager.UnloadAllExtensions()
|
||||||
}
|
}
|
||||||
|
|
||||||
// InvokeExtensionActionJSON invokes a custom action on an extension (e.g., button click handler)
|
|
||||||
// actionName is the JS function name to call (e.g., "startLogin", "authenticate", etc.)
|
|
||||||
func InvokeExtensionActionJSON(extensionID, actionName string) (string, error) {
|
func InvokeExtensionActionJSON(extensionID, actionName string) (string, error) {
|
||||||
manager := GetExtensionManager()
|
manager := GetExtensionManager()
|
||||||
result, err := manager.InvokeAction(extensionID, actionName)
|
result, err := manager.InvokeAction(extensionID, actionName)
|
||||||
@@ -1285,7 +1254,6 @@ func InvokeExtensionActionJSON(extensionID, actionName string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetExtensionPendingAuthJSON returns pending auth request for an extension
|
|
||||||
func GetExtensionPendingAuthJSON(extensionID string) (string, error) {
|
func GetExtensionPendingAuthJSON(extensionID string) (string, error) {
|
||||||
req := GetPendingAuthRequest(extensionID)
|
req := GetPendingAuthRequest(extensionID)
|
||||||
if req == nil {
|
if req == nil {
|
||||||
@@ -1306,12 +1274,10 @@ func GetExtensionPendingAuthJSON(extensionID string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetExtensionAuthCodeByID sets auth code for an extension (called from Flutter after OAuth callback)
|
|
||||||
func SetExtensionAuthCodeByID(extensionID, authCode string) {
|
func SetExtensionAuthCodeByID(extensionID, authCode string) {
|
||||||
SetExtensionAuthCode(extensionID, authCode)
|
SetExtensionAuthCode(extensionID, authCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetExtensionTokensByID sets tokens for an extension
|
|
||||||
func SetExtensionTokensByID(extensionID, accessToken, refreshToken string, expiresIn int) {
|
func SetExtensionTokensByID(extensionID, accessToken, refreshToken string, expiresIn int) {
|
||||||
var expiresAt time.Time
|
var expiresAt time.Time
|
||||||
if expiresIn > 0 {
|
if expiresIn > 0 {
|
||||||
@@ -1320,12 +1286,10 @@ func SetExtensionTokensByID(extensionID, accessToken, refreshToken string, expir
|
|||||||
SetExtensionTokens(extensionID, accessToken, refreshToken, expiresAt)
|
SetExtensionTokens(extensionID, accessToken, refreshToken, expiresAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearExtensionPendingAuthByID clears pending auth request for an extension
|
|
||||||
func ClearExtensionPendingAuthByID(extensionID string) {
|
func ClearExtensionPendingAuthByID(extensionID string) {
|
||||||
ClearPendingAuthRequest(extensionID)
|
ClearPendingAuthRequest(extensionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsExtensionAuthenticatedByID checks if an extension is authenticated
|
|
||||||
func IsExtensionAuthenticatedByID(extensionID string) bool {
|
func IsExtensionAuthenticatedByID(extensionID string) bool {
|
||||||
extensionAuthStateMu.RLock()
|
extensionAuthStateMu.RLock()
|
||||||
defer extensionAuthStateMu.RUnlock()
|
defer extensionAuthStateMu.RUnlock()
|
||||||
@@ -1342,7 +1306,6 @@ func IsExtensionAuthenticatedByID(extensionID string) bool {
|
|||||||
return state.IsAuthenticated
|
return state.IsAuthenticated
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAllPendingAuthRequestsJSON returns all pending auth requests
|
|
||||||
func GetAllPendingAuthRequestsJSON() (string, error) {
|
func GetAllPendingAuthRequestsJSON() (string, error) {
|
||||||
pendingAuthRequestsMu.RLock()
|
pendingAuthRequestsMu.RLock()
|
||||||
defer pendingAuthRequestsMu.RUnlock()
|
defer pendingAuthRequestsMu.RUnlock()
|
||||||
@@ -1386,12 +1349,10 @@ func GetPendingFFmpegCommandJSON(commandID string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetFFmpegCommandResultByID sets the result of an FFmpeg command
|
|
||||||
func SetFFmpegCommandResultByID(commandID string, success bool, output, errorMsg string) {
|
func SetFFmpegCommandResultByID(commandID string, success bool, output, errorMsg string) {
|
||||||
SetFFmpegCommandResult(commandID, success, output, errorMsg)
|
SetFFmpegCommandResult(commandID, success, output, errorMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAllPendingFFmpegCommandsJSON returns all pending FFmpeg commands
|
|
||||||
func GetAllPendingFFmpegCommandsJSON() (string, error) {
|
func GetAllPendingFFmpegCommandsJSON() (string, error) {
|
||||||
ffmpegCommandsMu.RLock()
|
ffmpegCommandsMu.RLock()
|
||||||
defer ffmpegCommandsMu.RUnlock()
|
defer ffmpegCommandsMu.RUnlock()
|
||||||
@@ -1417,8 +1378,6 @@ func GetAllPendingFFmpegCommandsJSON() (string, error) {
|
|||||||
|
|
||||||
// ==================== EXTENSION CUSTOM SEARCH ====================
|
// ==================== EXTENSION CUSTOM SEARCH ====================
|
||||||
|
|
||||||
// EnrichTrackWithExtensionJSON enriches track metadata using the source extension
|
|
||||||
// This is called lazily before download starts, allowing extension to fetch real ISRC etc.
|
|
||||||
func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error) {
|
func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error) {
|
||||||
manager := GetExtensionManager()
|
manager := GetExtensionManager()
|
||||||
ext, err := manager.GetExtension(extensionID)
|
ext, err := manager.GetExtension(extensionID)
|
||||||
@@ -1449,7 +1408,6 @@ func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error)
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CustomSearchWithExtensionJSON performs custom search using an extension
|
|
||||||
func CustomSearchWithExtensionJSON(extensionID, query string, optionsJSON string) (string, error) {
|
func CustomSearchWithExtensionJSON(extensionID, query string, optionsJSON string) (string, error) {
|
||||||
manager := GetExtensionManager()
|
manager := GetExtensionManager()
|
||||||
ext, err := manager.GetExtension(extensionID)
|
ext, err := manager.GetExtension(extensionID)
|
||||||
@@ -1502,7 +1460,6 @@ func CustomSearchWithExtensionJSON(extensionID, query string, optionsJSON string
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSearchProvidersJSON returns all extensions that provide custom search
|
|
||||||
func GetSearchProvidersJSON() (string, error) {
|
func GetSearchProvidersJSON() (string, error) {
|
||||||
manager := GetExtensionManager()
|
manager := GetExtensionManager()
|
||||||
providers := manager.GetSearchProviders()
|
providers := manager.GetSearchProviders()
|
||||||
@@ -1666,8 +1623,6 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// FindURLHandlerJSON finds an extension that can handle the given URL
|
|
||||||
// Returns extension ID or empty string if none found
|
|
||||||
func FindURLHandlerJSON(url string) string {
|
func FindURLHandlerJSON(url string) string {
|
||||||
manager := GetExtensionManager()
|
manager := GetExtensionManager()
|
||||||
handler := manager.FindURLHandler(url)
|
handler := manager.FindURLHandler(url)
|
||||||
@@ -1677,7 +1632,6 @@ func FindURLHandlerJSON(url string) string {
|
|||||||
return handler.extension.ID
|
return handler.extension.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAlbumWithExtensionJSON gets album tracks using an extension
|
|
||||||
func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) {
|
func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) {
|
||||||
manager := GetExtensionManager()
|
manager := GetExtensionManager()
|
||||||
ext, err := manager.GetExtension(extensionID)
|
ext, err := manager.GetExtension(extensionID)
|
||||||
@@ -1752,7 +1706,6 @@ func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPlaylistWithExtensionJSON gets playlist tracks using an extension
|
|
||||||
func GetPlaylistWithExtensionJSON(extensionID, playlistID string) (string, error) {
|
func GetPlaylistWithExtensionJSON(extensionID, playlistID string) (string, error) {
|
||||||
manager := GetExtensionManager()
|
manager := GetExtensionManager()
|
||||||
ext, err := manager.GetExtension(extensionID)
|
ext, err := manager.GetExtension(extensionID)
|
||||||
@@ -1844,7 +1797,6 @@ func GetPlaylistWithExtensionJSON(extensionID, playlistID string) (string, error
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetArtistWithExtensionJSON gets artist info and albums using an extension
|
|
||||||
func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) {
|
func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) {
|
||||||
manager := GetExtensionManager()
|
manager := GetExtensionManager()
|
||||||
ext, err := manager.GetExtension(extensionID)
|
ext, err := manager.GetExtension(extensionID)
|
||||||
@@ -1928,7 +1880,6 @@ func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetURLHandlersJSON returns all extensions that handle custom URLs
|
|
||||||
func GetURLHandlersJSON() (string, error) {
|
func GetURLHandlersJSON() (string, error) {
|
||||||
manager := GetExtensionManager()
|
manager := GetExtensionManager()
|
||||||
handlers := manager.GetURLHandlers()
|
handlers := manager.GetURLHandlers()
|
||||||
@@ -1972,7 +1923,6 @@ func RunPostProcessingJSON(filePath, metadataJSON string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPostProcessingProvidersJSON returns all extensions that provide post-processing
|
|
||||||
func GetPostProcessingProvidersJSON() (string, error) {
|
func GetPostProcessingProvidersJSON() (string, error) {
|
||||||
manager := GetExtensionManager()
|
manager := GetExtensionManager()
|
||||||
providers := manager.GetPostProcessingProviders()
|
providers := manager.GetPostProcessingProviders()
|
||||||
@@ -2005,13 +1955,11 @@ func GetPostProcessingProvidersJSON() (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// InitExtensionStoreJSON initializes the extension store with cache directory
|
|
||||||
func InitExtensionStoreJSON(cacheDir string) error {
|
func InitExtensionStoreJSON(cacheDir string) error {
|
||||||
InitExtensionStore(cacheDir)
|
InitExtensionStore(cacheDir)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetStoreExtensionsJSON returns all extensions from the store with installation status
|
|
||||||
func GetStoreExtensionsJSON(forceRefresh bool) (string, error) {
|
func GetStoreExtensionsJSON(forceRefresh bool) (string, error) {
|
||||||
store := GetExtensionStore()
|
store := GetExtensionStore()
|
||||||
if store == nil {
|
if store == nil {
|
||||||
@@ -2035,7 +1983,6 @@ func GetStoreExtensionsJSON(forceRefresh bool) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchStoreExtensionsJSON searches extensions in the store
|
|
||||||
func SearchStoreExtensionsJSON(query, category string) (string, error) {
|
func SearchStoreExtensionsJSON(query, category string) (string, error) {
|
||||||
store := GetExtensionStore()
|
store := GetExtensionStore()
|
||||||
if store == nil {
|
if store == nil {
|
||||||
@@ -2055,7 +2002,6 @@ func SearchStoreExtensionsJSON(query, category string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetStoreCategoriesJSON returns all available categories
|
|
||||||
func GetStoreCategoriesJSON() (string, error) {
|
func GetStoreCategoriesJSON() (string, error) {
|
||||||
store := GetExtensionStore()
|
store := GetExtensionStore()
|
||||||
if store == nil {
|
if store == nil {
|
||||||
@@ -2071,8 +2017,6 @@ func GetStoreCategoriesJSON() (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DownloadStoreExtensionJSON downloads an extension from the store
|
|
||||||
// Returns the path to the downloaded file
|
|
||||||
func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) {
|
func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) {
|
||||||
store := GetExtensionStore()
|
store := GetExtensionStore()
|
||||||
if store == nil {
|
if store == nil {
|
||||||
@@ -2088,7 +2032,6 @@ func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) {
|
|||||||
return destPath, nil
|
return destPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearStoreCacheJSON clears the store cache
|
|
||||||
func ClearStoreCacheJSON() error {
|
func ClearStoreCacheJSON() error {
|
||||||
store := GetExtensionStore()
|
store := GetExtensionStore()
|
||||||
if store == nil {
|
if store == nil {
|
||||||
@@ -2139,12 +2082,10 @@ func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Du
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetExtensionHomeFeedJSON calls getHomeFeed on any extension that supports it
|
|
||||||
func GetExtensionHomeFeedJSON(extensionID string) (string, error) {
|
func GetExtensionHomeFeedJSON(extensionID string) (string, error) {
|
||||||
return callExtensionFunctionJSON(extensionID, "getHomeFeed", 60*time.Second)
|
return callExtensionFunctionJSON(extensionID, "getHomeFeed", 60*time.Second)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetExtensionBrowseCategoriesJSON calls getBrowseCategories on any extension that supports it
|
|
||||||
func GetExtensionBrowseCategoriesJSON(extensionID string) (string, error) {
|
func GetExtensionBrowseCategoriesJSON(extensionID string) (string, error) {
|
||||||
return callExtensionFunctionJSON(extensionID, "getBrowseCategories", 30*time.Second)
|
return callExtensionFunctionJSON(extensionID, "getBrowseCategories", 30*time.Second)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ type LoadedExtension struct {
|
|||||||
IconPath string `json:"icon_path"`
|
IconPath string `json:"icon_path"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExtensionManager manages all loaded extensions
|
|
||||||
type ExtensionManager struct {
|
type ExtensionManager struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
extensions map[string]*LoadedExtension
|
extensions map[string]*LoadedExtension
|
||||||
@@ -283,7 +282,6 @@ func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnloadExtension unloads an extension by ID
|
|
||||||
func (m *ExtensionManager) UnloadExtension(extensionID string) error {
|
func (m *ExtensionManager) UnloadExtension(extensionID string) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
@@ -311,7 +309,6 @@ func (m *ExtensionManager) UnloadExtension(extensionID string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns error if extension not found (gomobile compatible)
|
|
||||||
func (m *ExtensionManager) GetExtension(extensionID string) (*LoadedExtension, error) {
|
func (m *ExtensionManager) GetExtension(extensionID string) (*LoadedExtension, error) {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
@@ -323,7 +320,6 @@ func (m *ExtensionManager) GetExtension(extensionID string) (*LoadedExtension, e
|
|||||||
return ext, nil
|
return ext, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAllExtensions returns all loaded extensions
|
|
||||||
func (m *ExtensionManager) GetAllExtensions() []*LoadedExtension {
|
func (m *ExtensionManager) GetAllExtensions() []*LoadedExtension {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
@@ -356,7 +352,6 @@ func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadExtensionsFromDirectory scans a directory and loads all valid extensions
|
|
||||||
func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string, []error) {
|
func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string, []error) {
|
||||||
var loaded []string
|
var loaded []string
|
||||||
var errors []error
|
var errors []error
|
||||||
@@ -456,7 +451,6 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
|
|||||||
return ext, nil
|
return ext, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveExtension completely removes an extension (unload + delete files)
|
|
||||||
func (m *ExtensionManager) RemoveExtension(extensionID string) error {
|
func (m *ExtensionManager) RemoveExtension(extensionID string) error {
|
||||||
ext, err := m.GetExtension(extensionID)
|
ext, err := m.GetExtension(extensionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -637,8 +631,6 @@ type ExtensionUpgradeInfo struct {
|
|||||||
IsInstalled bool `json:"is_installed"`
|
IsInstalled bool `json:"is_installed"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkExtensionUpgradeInternal checks if a package file is an upgrade for an existing extension
|
|
||||||
// Internal function that returns struct
|
|
||||||
func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) {
|
func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) {
|
||||||
// Validate file extension
|
// Validate file extension
|
||||||
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
||||||
@@ -714,7 +706,6 @@ func (m *ExtensionManager) CheckExtensionUpgradeJSON(filePath string) (string, e
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetInstalledExtensionsJSON returns all extensions as JSON for Flutter
|
|
||||||
func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
||||||
extensions := m.GetAllExtensions()
|
extensions := m.GetAllExtensions()
|
||||||
|
|
||||||
@@ -809,8 +800,6 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Extension Lifecycle ====================
|
|
||||||
|
|
||||||
func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[string]interface{}) error {
|
func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[string]interface{}) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
@@ -923,7 +912,6 @@ func (m *ExtensionManager) CleanupExtension(extensionID string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnloadAllExtensions unloads all extensions gracefully
|
|
||||||
func (m *ExtensionManager) UnloadAllExtensions() {
|
func (m *ExtensionManager) UnloadAllExtensions() {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
extensionIDs := make([]string, 0, len(m.extensions))
|
extensionIDs := make([]string, 0, len(m.extensions))
|
||||||
@@ -940,7 +928,6 @@ func (m *ExtensionManager) UnloadAllExtensions() {
|
|||||||
GoLog("[Extension] All extensions unloaded\n")
|
GoLog("[Extension] All extensions unloaded\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// The function is called as extension.<actionName>() and can return a result
|
|
||||||
func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (map[string]interface{}, error) {
|
func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (map[string]interface{}, error) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ExtensionType represents the type of extension
|
|
||||||
type ExtensionType string
|
type ExtensionType string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -15,7 +14,6 @@ const (
|
|||||||
ExtensionTypeDownloadProvider ExtensionType = "download_provider"
|
ExtensionTypeDownloadProvider ExtensionType = "download_provider"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SettingType represents the type of a setting field
|
|
||||||
type SettingType string
|
type SettingType string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -26,14 +24,12 @@ const (
|
|||||||
SettingTypeButton SettingType = "button" // Action button that calls a JS function
|
SettingTypeButton SettingType = "button" // Action button that calls a JS function
|
||||||
)
|
)
|
||||||
|
|
||||||
// ExtensionPermissions defines what resources an extension can access
|
|
||||||
type ExtensionPermissions struct {
|
type ExtensionPermissions struct {
|
||||||
Network []string `json:"network"` // List of allowed domains
|
Network []string `json:"network"`
|
||||||
Storage bool `json:"storage"` // Whether extension can use storage API
|
Storage bool `json:"storage"`
|
||||||
File bool `json:"file"` // Whether extension can use file API
|
File bool `json:"file"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExtensionSetting defines a configurable setting for an extension
|
|
||||||
type ExtensionSetting struct {
|
type ExtensionSetting struct {
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
Type SettingType `json:"type"`
|
Type SettingType `json:"type"`
|
||||||
@@ -42,19 +38,17 @@ type ExtensionSetting struct {
|
|||||||
Required bool `json:"required,omitempty"`
|
Required bool `json:"required,omitempty"`
|
||||||
Secret bool `json:"secret,omitempty"`
|
Secret bool `json:"secret,omitempty"`
|
||||||
Default interface{} `json:"default,omitempty"`
|
Default interface{} `json:"default,omitempty"`
|
||||||
Options []string `json:"options,omitempty"` // For select type
|
Options []string `json:"options,omitempty"`
|
||||||
Action string `json:"action,omitempty"` // For button type: JS function name to call (e.g., "startLogin")
|
Action string `json:"action,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// QualityOption represents a quality option for download providers
|
|
||||||
type QualityOption struct {
|
type QualityOption struct {
|
||||||
ID string `json:"id"` // Unique identifier (e.g., "mp3_320", "opus_128")
|
ID string `json:"id"`
|
||||||
Label string `json:"label"` // Display name (e.g., "MP3 320kbps")
|
Label string `json:"label"`
|
||||||
Description string `json:"description"` // Optional description (e.g., "Best quality MP3")
|
Description string `json:"description"`
|
||||||
Settings []QualitySpecificSetting `json:"settings,omitempty"` // Quality-specific settings
|
Settings []QualitySpecificSetting `json:"settings,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// QualitySpecificSetting represents a setting that's specific to a quality option
|
|
||||||
type QualitySpecificSetting struct {
|
type QualitySpecificSetting struct {
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
Type SettingType `json:"type"`
|
Type SettingType `json:"type"`
|
||||||
@@ -63,57 +57,50 @@ type QualitySpecificSetting struct {
|
|||||||
Required bool `json:"required,omitempty"`
|
Required bool `json:"required,omitempty"`
|
||||||
Secret bool `json:"secret,omitempty"`
|
Secret bool `json:"secret,omitempty"`
|
||||||
Default interface{} `json:"default,omitempty"`
|
Default interface{} `json:"default,omitempty"`
|
||||||
Options []string `json:"options,omitempty"` // For select type
|
Options []string `json:"options,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchFilter defines a filter option for search
|
|
||||||
type SearchFilter struct {
|
type SearchFilter struct {
|
||||||
ID string `json:"id"` // Filter identifier (e.g., "track", "album", "artist", "playlist")
|
ID string `json:"id"`
|
||||||
Label string `json:"label,omitempty"` // Display label (e.g., "Songs", "Albums", "Artists", "Playlists")
|
Label string `json:"label,omitempty"`
|
||||||
Icon string `json:"icon,omitempty"` // Optional icon name
|
Icon string `json:"icon,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchBehaviorConfig defines custom search behavior for an extension
|
|
||||||
type SearchBehaviorConfig struct {
|
type SearchBehaviorConfig struct {
|
||||||
Enabled bool `json:"enabled"` // Whether extension provides custom search
|
Enabled bool `json:"enabled"`
|
||||||
Placeholder string `json:"placeholder,omitempty"` // Placeholder text for search box
|
Placeholder string `json:"placeholder,omitempty"`
|
||||||
Primary bool `json:"primary,omitempty"` // If true, show as primary search tab
|
Primary bool `json:"primary,omitempty"`
|
||||||
Icon string `json:"icon,omitempty"` // Icon for search tab
|
Icon string `json:"icon,omitempty"`
|
||||||
ThumbnailRatio string `json:"thumbnailRatio,omitempty"` // Thumbnail aspect ratio: "square" (1:1), "wide" (16:9), "portrait" (2:3)
|
ThumbnailRatio string `json:"thumbnailRatio,omitempty"`
|
||||||
ThumbnailWidth int `json:"thumbnailWidth,omitempty"` // Custom thumbnail width in pixels
|
ThumbnailWidth int `json:"thumbnailWidth,omitempty"`
|
||||||
ThumbnailHeight int `json:"thumbnailHeight,omitempty"` // Custom thumbnail height in pixels
|
ThumbnailHeight int `json:"thumbnailHeight,omitempty"`
|
||||||
Filters []SearchFilter `json:"filters,omitempty"` // Available search filters (e.g., track, album, artist, playlist)
|
Filters []SearchFilter `json:"filters,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// URLHandlerConfig defines custom URL handling for an extension
|
|
||||||
type URLHandlerConfig struct {
|
type URLHandlerConfig struct {
|
||||||
Enabled bool `json:"enabled"` // Whether extension handles URLs
|
Enabled bool `json:"enabled"`
|
||||||
Patterns []string `json:"patterns,omitempty"` // URL patterns to match (e.g., "music.youtube.com", "soundcloud.com")
|
Patterns []string `json:"patterns,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TrackMatchingConfig defines custom track matching behavior
|
|
||||||
type TrackMatchingConfig struct {
|
type TrackMatchingConfig struct {
|
||||||
CustomMatching bool `json:"customMatching"` // Whether extension handles matching
|
CustomMatching bool `json:"customMatching"`
|
||||||
Strategy string `json:"strategy,omitempty"` // "isrc", "name", "duration", "custom"
|
Strategy string `json:"strategy,omitempty"`
|
||||||
DurationTolerance int `json:"durationTolerance,omitempty"` // Tolerance in seconds for duration matching
|
DurationTolerance int `json:"durationTolerance,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PostProcessingHook defines a post-processing hook
|
|
||||||
type PostProcessingHook struct {
|
type PostProcessingHook struct {
|
||||||
ID string `json:"id"` // Unique identifier
|
ID string `json:"id"`
|
||||||
Name string `json:"name"` // Display name
|
Name string `json:"name"`
|
||||||
Description string `json:"description,omitempty"` // Description
|
Description string `json:"description,omitempty"`
|
||||||
DefaultEnabled bool `json:"defaultEnabled,omitempty"` // Whether enabled by default
|
DefaultEnabled bool `json:"defaultEnabled,omitempty"`
|
||||||
SupportedFormats []string `json:"supportedFormats,omitempty"` // Supported file formats (e.g., ["flac", "mp3"])
|
SupportedFormats []string `json:"supportedFormats,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PostProcessingConfig defines post-processing capabilities
|
|
||||||
type PostProcessingConfig struct {
|
type PostProcessingConfig struct {
|
||||||
Enabled bool `json:"enabled"` // Whether extension provides post-processing
|
Enabled bool `json:"enabled"`
|
||||||
Hooks []PostProcessingHook `json:"hooks,omitempty"` // Available hooks
|
Hooks []PostProcessingHook `json:"hooks,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExtensionManifest represents the manifest.json of an extension
|
|
||||||
type ExtensionManifest struct {
|
type ExtensionManifest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
@@ -121,22 +108,21 @@ type ExtensionManifest struct {
|
|||||||
Author string `json:"author"`
|
Author string `json:"author"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Homepage string `json:"homepage,omitempty"`
|
Homepage string `json:"homepage,omitempty"`
|
||||||
Icon string `json:"icon,omitempty"` // Icon filename (e.g., "icon.png")
|
Icon string `json:"icon,omitempty"`
|
||||||
Types []ExtensionType `json:"type"`
|
Types []ExtensionType `json:"type"`
|
||||||
Permissions ExtensionPermissions `json:"permissions"`
|
Permissions ExtensionPermissions `json:"permissions"`
|
||||||
Settings []ExtensionSetting `json:"settings,omitempty"`
|
Settings []ExtensionSetting `json:"settings,omitempty"`
|
||||||
QualityOptions []QualityOption `json:"qualityOptions,omitempty"` // Custom quality options for download providers
|
QualityOptions []QualityOption `json:"qualityOptions,omitempty"`
|
||||||
MinAppVersion string `json:"minAppVersion,omitempty"`
|
MinAppVersion string `json:"minAppVersion,omitempty"`
|
||||||
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"` // If true, don't enrich metadata from Deezer/Spotify
|
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"`
|
||||||
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"` // If true, don't fallback to built-in providers (tidal/qobuz/amazon)
|
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"`
|
||||||
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"` // Custom search behavior
|
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"`
|
||||||
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"` // Custom URL handling
|
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"`
|
||||||
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"` // Custom track matching
|
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"`
|
||||||
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"` // Post-processing hooks
|
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"`
|
||||||
Capabilities map[string]interface{} `json:"capabilities,omitempty"` // Extension capabilities (homeFeed, browseCategories, etc.)
|
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ManifestValidationError represents a validation error in the manifest
|
|
||||||
type ManifestValidationError struct {
|
type ManifestValidationError struct {
|
||||||
Field string
|
Field string
|
||||||
Message string
|
Message string
|
||||||
@@ -146,7 +132,6 @@ func (e *ManifestValidationError) Error() string {
|
|||||||
return fmt.Sprintf("manifest validation error: %s - %s", e.Field, e.Message)
|
return fmt.Sprintf("manifest validation error: %s - %s", e.Field, e.Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseManifest parses and validates a manifest from JSON bytes
|
|
||||||
func ParseManifest(data []byte) (*ExtensionManifest, error) {
|
func ParseManifest(data []byte) (*ExtensionManifest, error) {
|
||||||
var manifest ExtensionManifest
|
var manifest ExtensionManifest
|
||||||
if err := json.Unmarshal(data, &manifest); err != nil {
|
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||||
@@ -225,7 +210,6 @@ func (m *ExtensionManifest) Validate() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasType checks if the extension has a specific type
|
|
||||||
func (m *ExtensionManifest) HasType(t ExtensionType) bool {
|
func (m *ExtensionManifest) HasType(t ExtensionType) bool {
|
||||||
for _, et := range m.Types {
|
for _, et := range m.Types {
|
||||||
if et == t {
|
if et == t {
|
||||||
@@ -235,17 +219,14 @@ func (m *ExtensionManifest) HasType(t ExtensionType) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsMetadataProvider returns true if extension provides metadata
|
|
||||||
func (m *ExtensionManifest) IsMetadataProvider() bool {
|
func (m *ExtensionManifest) IsMetadataProvider() bool {
|
||||||
return m.HasType(ExtensionTypeMetadataProvider)
|
return m.HasType(ExtensionTypeMetadataProvider)
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsDownloadProvider returns true if extension provides downloads
|
|
||||||
func (m *ExtensionManifest) IsDownloadProvider() bool {
|
func (m *ExtensionManifest) IsDownloadProvider() bool {
|
||||||
return m.HasType(ExtensionTypeDownloadProvider)
|
return m.HasType(ExtensionTypeDownloadProvider)
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsDomainAllowed checks if a domain is in the allowed network permissions
|
|
||||||
func (m *ExtensionManifest) IsDomainAllowed(domain string) bool {
|
func (m *ExtensionManifest) IsDomainAllowed(domain string) bool {
|
||||||
domain = strings.ToLower(strings.TrimSpace(domain))
|
domain = strings.ToLower(strings.TrimSpace(domain))
|
||||||
for _, allowed := range m.Permissions.Network {
|
for _, allowed := range m.Permissions.Network {
|
||||||
@@ -264,27 +245,22 @@ func (m *ExtensionManifest) IsDomainAllowed(domain string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasCustomSearch returns true if extension provides custom search
|
|
||||||
func (m *ExtensionManifest) HasCustomSearch() bool {
|
func (m *ExtensionManifest) HasCustomSearch() bool {
|
||||||
return m.SearchBehavior != nil && m.SearchBehavior.Enabled
|
return m.SearchBehavior != nil && m.SearchBehavior.Enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasCustomMatching returns true if extension provides custom track matching
|
|
||||||
func (m *ExtensionManifest) HasCustomMatching() bool {
|
func (m *ExtensionManifest) HasCustomMatching() bool {
|
||||||
return m.TrackMatching != nil && m.TrackMatching.CustomMatching
|
return m.TrackMatching != nil && m.TrackMatching.CustomMatching
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasPostProcessing returns true if extension provides post-processing
|
|
||||||
func (m *ExtensionManifest) HasPostProcessing() bool {
|
func (m *ExtensionManifest) HasPostProcessing() bool {
|
||||||
return m.PostProcessing != nil && m.PostProcessing.Enabled
|
return m.PostProcessing != nil && m.PostProcessing.Enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasURLHandler returns true if extension handles custom URLs
|
|
||||||
func (m *ExtensionManifest) HasURLHandler() bool {
|
func (m *ExtensionManifest) HasURLHandler() bool {
|
||||||
return m.URLHandler != nil && m.URLHandler.Enabled && len(m.URLHandler.Patterns) > 0
|
return m.URLHandler != nil && m.URLHandler.Enabled && len(m.URLHandler.Patterns) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// MatchesURL checks if a URL matches any of the extension's URL patterns
|
|
||||||
func (m *ExtensionManifest) MatchesURL(urlStr string) bool {
|
func (m *ExtensionManifest) MatchesURL(urlStr string) bool {
|
||||||
if !m.HasURLHandler() {
|
if !m.HasURLHandler() {
|
||||||
return false
|
return false
|
||||||
@@ -301,7 +277,6 @@ func (m *ExtensionManifest) MatchesURL(urlStr string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPostProcessingHooks returns all post-processing hooks
|
|
||||||
func (m *ExtensionManifest) GetPostProcessingHooks() []PostProcessingHook {
|
func (m *ExtensionManifest) GetPostProcessingHooks() []PostProcessingHook {
|
||||||
if m.PostProcessing == nil {
|
if m.PostProcessing == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -309,7 +284,6 @@ func (m *ExtensionManifest) GetPostProcessingHooks() []PostProcessingHook {
|
|||||||
return m.PostProcessing.Hooks
|
return m.PostProcessing.Hooks
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToJSON serializes the manifest to JSON
|
|
||||||
func (m *ExtensionManifest) ToJSON() ([]byte, error) {
|
func (m *ExtensionManifest) ToJSON() ([]byte, error) {
|
||||||
return json.Marshal(m)
|
return json.Marshal(m)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -732,7 +732,7 @@ func GetMetadataProviderPriority() []string {
|
|||||||
// isBuiltInProvider checks if a provider ID is a built-in provider
|
// isBuiltInProvider checks if a provider ID is a built-in provider
|
||||||
func isBuiltInProvider(providerID string) bool {
|
func isBuiltInProvider(providerID string) bool {
|
||||||
switch providerID {
|
switch providerID {
|
||||||
case "tidal", "qobuz", "amazon":
|
case "tidal", "qobuz", "amazon", "deezer":
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
@@ -748,6 +748,21 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
priority := GetProviderPriority()
|
priority := GetProviderPriority()
|
||||||
extManager := GetExtensionManager()
|
extManager := GetExtensionManager()
|
||||||
|
|
||||||
|
// If req.Service is a built-in provider, prioritize it first
|
||||||
|
// This handles user's explicit selection from the service picker
|
||||||
|
if req.Service != "" && isBuiltInProvider(req.Service) {
|
||||||
|
GoLog("[DownloadWithExtensionFallback] User selected service: %s, prioritizing it first\n", req.Service)
|
||||||
|
// Reorder priority to put req.Service first
|
||||||
|
newPriority := []string{req.Service}
|
||||||
|
for _, p := range priority {
|
||||||
|
if p != req.Service {
|
||||||
|
newPriority = append(newPriority, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
priority = newPriority
|
||||||
|
GoLog("[DownloadWithExtensionFallback] New priority order: %v\n", priority)
|
||||||
|
}
|
||||||
|
|
||||||
var lastErr error
|
var lastErr error
|
||||||
var skipBuiltIn bool // If source extension has skipBuiltInFallback, don't try built-in providers
|
var skipBuiltIn bool // If source extension has skipBuiltInFallback, don't try built-in providers
|
||||||
|
|
||||||
|
|||||||
@@ -23,9 +23,8 @@ type ExtensionAuthState struct {
|
|||||||
RefreshToken string
|
RefreshToken string
|
||||||
ExpiresAt time.Time
|
ExpiresAt time.Time
|
||||||
IsAuthenticated bool
|
IsAuthenticated bool
|
||||||
// PKCE support
|
PKCEVerifier string
|
||||||
PKCEVerifier string
|
PKCEChallenge string
|
||||||
PKCEChallenge string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type PendingAuthRequest struct {
|
type PendingAuthRequest struct {
|
||||||
@@ -39,7 +38,6 @@ var (
|
|||||||
pendingAuthRequestsMu sync.RWMutex
|
pendingAuthRequestsMu sync.RWMutex
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetPendingAuthRequest returns pending auth request for an extension (called from Flutter)
|
|
||||||
func GetPendingAuthRequest(extensionID string) *PendingAuthRequest {
|
func GetPendingAuthRequest(extensionID string) *PendingAuthRequest {
|
||||||
pendingAuthRequestsMu.RLock()
|
pendingAuthRequestsMu.RLock()
|
||||||
defer pendingAuthRequestsMu.RUnlock()
|
defer pendingAuthRequestsMu.RUnlock()
|
||||||
@@ -201,7 +199,6 @@ func (r *ExtensionRuntime) SetSettings(settings map[string]interface{}) {
|
|||||||
r.settings = settings
|
r.settings = settings
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterAPIs registers all sandboxed APIs to the Goja VM
|
|
||||||
func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||||
r.vm = vm
|
r.vm = vm
|
||||||
|
|
||||||
@@ -212,7 +209,7 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
|||||||
httpObj.Set("put", r.httpPut)
|
httpObj.Set("put", r.httpPut)
|
||||||
httpObj.Set("delete", r.httpDelete)
|
httpObj.Set("delete", r.httpDelete)
|
||||||
httpObj.Set("patch", r.httpPatch)
|
httpObj.Set("patch", r.httpPatch)
|
||||||
httpObj.Set("request", r.httpRequest) // Generic HTTP request (GET, POST, PUT, DELETE, etc.)
|
httpObj.Set("request", r.httpRequest)
|
||||||
httpObj.Set("clearCookies", r.httpClearCookies)
|
httpObj.Set("clearCookies", r.httpClearCookies)
|
||||||
vm.Set("http", httpObj)
|
vm.Set("http", httpObj)
|
||||||
|
|
||||||
@@ -222,7 +219,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
|||||||
storageObj.Set("remove", r.storageRemove)
|
storageObj.Set("remove", r.storageRemove)
|
||||||
vm.Set("storage", storageObj)
|
vm.Set("storage", storageObj)
|
||||||
|
|
||||||
// Secure Credentials API (encrypted storage for sensitive data)
|
|
||||||
credentialsObj := vm.NewObject()
|
credentialsObj := vm.NewObject()
|
||||||
credentialsObj.Set("store", r.credentialsStore)
|
credentialsObj.Set("store", r.credentialsStore)
|
||||||
credentialsObj.Set("get", r.credentialsGet)
|
credentialsObj.Set("get", r.credentialsGet)
|
||||||
@@ -237,7 +233,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
|||||||
authObj.Set("clearAuth", r.authClear)
|
authObj.Set("clearAuth", r.authClear)
|
||||||
authObj.Set("isAuthenticated", r.authIsAuthenticated)
|
authObj.Set("isAuthenticated", r.authIsAuthenticated)
|
||||||
authObj.Set("getTokens", r.authGetTokens)
|
authObj.Set("getTokens", r.authGetTokens)
|
||||||
// PKCE support
|
|
||||||
authObj.Set("generatePKCE", r.authGeneratePKCE)
|
authObj.Set("generatePKCE", r.authGeneratePKCE)
|
||||||
authObj.Set("getPKCE", r.authGetPKCE)
|
authObj.Set("getPKCE", r.authGetPKCE)
|
||||||
authObj.Set("startOAuthWithPKCE", r.authStartOAuthWithPKCE)
|
authObj.Set("startOAuthWithPKCE", r.authStartOAuthWithPKCE)
|
||||||
@@ -279,14 +274,12 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
|||||||
utilsObj.Set("hmacSHA1", r.hmacSHA1)
|
utilsObj.Set("hmacSHA1", r.hmacSHA1)
|
||||||
utilsObj.Set("parseJSON", r.parseJSON)
|
utilsObj.Set("parseJSON", r.parseJSON)
|
||||||
utilsObj.Set("stringifyJSON", r.stringifyJSON)
|
utilsObj.Set("stringifyJSON", r.stringifyJSON)
|
||||||
// Crypto utilities for developers
|
|
||||||
utilsObj.Set("encrypt", r.cryptoEncrypt)
|
utilsObj.Set("encrypt", r.cryptoEncrypt)
|
||||||
utilsObj.Set("decrypt", r.cryptoDecrypt)
|
utilsObj.Set("decrypt", r.cryptoDecrypt)
|
||||||
utilsObj.Set("generateKey", r.cryptoGenerateKey)
|
utilsObj.Set("generateKey", r.cryptoGenerateKey)
|
||||||
utilsObj.Set("randomUserAgent", r.randomUserAgent)
|
utilsObj.Set("randomUserAgent", r.randomUserAgent)
|
||||||
vm.Set("utils", utilsObj)
|
vm.Set("utils", utilsObj)
|
||||||
|
|
||||||
// Log object (already set in extension_manager.go, but we can enhance it)
|
|
||||||
logObj := vm.NewObject()
|
logObj := vm.NewObject()
|
||||||
logObj.Set("debug", r.logDebug)
|
logObj.Set("debug", r.logDebug)
|
||||||
logObj.Set("info", r.logInfo)
|
logObj.Set("info", r.logInfo)
|
||||||
@@ -298,10 +291,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
|||||||
gobackendObj.Set("sanitizeFilename", r.sanitizeFilenameWrapper)
|
gobackendObj.Set("sanitizeFilename", r.sanitizeFilenameWrapper)
|
||||||
vm.Set("gobackend", gobackendObj)
|
vm.Set("gobackend", gobackendObj)
|
||||||
|
|
||||||
// ==================== Browser-like Polyfills ====================
|
|
||||||
// These make porting browser/Node.js libraries easier
|
|
||||||
|
|
||||||
// Global fetch() - Promise-style HTTP API (browser-compatible)
|
|
||||||
vm.Set("fetch", r.fetchPolyfill)
|
vm.Set("fetch", r.fetchPolyfill)
|
||||||
|
|
||||||
vm.Set("atob", r.atobPolyfill)
|
vm.Set("atob", r.atobPolyfill)
|
||||||
|
|||||||
@@ -70,13 +70,11 @@ func (r *ExtensionRuntime) authGetCode(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(state.AuthCode)
|
return r.vm.ToValue(state.AuthCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// authSetCode sets auth code and tokens (can be called by extension after token exchange)
|
|
||||||
func (r *ExtensionRuntime) authSetCode(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) authSetCode(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(false)
|
return r.vm.ToValue(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Can accept either just auth code or an object with tokens
|
|
||||||
arg := call.Arguments[0].Export()
|
arg := call.Arguments[0].Export()
|
||||||
|
|
||||||
extensionAuthStateMu.Lock()
|
extensionAuthStateMu.Lock()
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import (
|
|||||||
|
|
||||||
// ==================== File API (Sandboxed) ====================
|
// ==================== File API (Sandboxed) ====================
|
||||||
|
|
||||||
// List of allowed directories for file operations (set by Go backend for download operations)
|
|
||||||
var (
|
var (
|
||||||
allowedDownloadDirs []string
|
allowedDownloadDirs []string
|
||||||
allowedDownloadDirsMu sync.RWMutex
|
allowedDownloadDirsMu sync.RWMutex
|
||||||
@@ -49,9 +48,6 @@ func isPathInAllowedDirs(absPath string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// validatePath checks if the path is within the extension's sandbox
|
|
||||||
// Security: Absolute paths are BLOCKED unless they're in allowed download directories
|
|
||||||
// Extensions should use relative paths for their own data storage
|
|
||||||
func (r *ExtensionRuntime) validatePath(path string) (string, error) {
|
func (r *ExtensionRuntime) validatePath(path string) (string, error) {
|
||||||
// Check if extension has file permission
|
// Check if extension has file permission
|
||||||
if !r.manifest.Permissions.File {
|
if !r.manifest.Permissions.File {
|
||||||
|
|||||||
@@ -14,14 +14,12 @@ import (
|
|||||||
|
|
||||||
// ==================== HTTP API (Sandboxed) ====================
|
// ==================== HTTP API (Sandboxed) ====================
|
||||||
|
|
||||||
// HTTPResponse represents the response from an HTTP request
|
|
||||||
type HTTPResponse struct {
|
type HTTPResponse struct {
|
||||||
StatusCode int `json:"statusCode"`
|
StatusCode int `json:"statusCode"`
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
Headers map[string]string `json:"headers"`
|
Headers map[string]string `json:"headers"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateDomain checks if the domain is allowed by the extension's permissions
|
|
||||||
func (r *ExtensionRuntime) validateDomain(urlStr string) error {
|
func (r *ExtensionRuntime) validateDomain(urlStr string) error {
|
||||||
parsed, err := url.Parse(urlStr)
|
parsed, err := url.Parse(urlStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -42,7 +40,6 @@ func (r *ExtensionRuntime) validateDomain(urlStr string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// httpGet performs a GET request (sandboxed)
|
|
||||||
func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -76,16 +73,14 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set headers - user headers first
|
|
||||||
for k, v := range headers {
|
for k, v := range headers {
|
||||||
req.Header.Set(k, v)
|
req.Header.Set(k, v)
|
||||||
}
|
}
|
||||||
// Only set default User-Agent if not provided by extension
|
|
||||||
if req.Header.Get("User-Agent") == "" {
|
if req.Header.Get("User-Agent") == "" {
|
||||||
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
|
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute request
|
|
||||||
resp, err := r.httpClient.Do(req)
|
resp, err := r.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -101,26 +96,24 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract response headers - return all values as arrays for multi-value headers (cookies, etc.)
|
|
||||||
respHeaders := make(map[string]interface{})
|
respHeaders := make(map[string]interface{})
|
||||||
for k, v := range resp.Header {
|
for k, v := range resp.Header {
|
||||||
if len(v) == 1 {
|
if len(v) == 1 {
|
||||||
respHeaders[k] = v[0]
|
respHeaders[k] = v[0]
|
||||||
} else {
|
} else {
|
||||||
respHeaders[k] = v // Return as array if multiple values
|
respHeaders[k] = v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"statusCode": resp.StatusCode,
|
"statusCode": resp.StatusCode,
|
||||||
"status": resp.StatusCode, // Alias for convenience
|
"status": resp.StatusCode,
|
||||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||||
"body": string(body),
|
"body": string(body),
|
||||||
"headers": respHeaders,
|
"headers": respHeaders,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// httpPost performs a POST request (sandboxed)
|
|
||||||
func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -137,7 +130,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get body if provided - support both string and object
|
|
||||||
var bodyStr string
|
var bodyStr string
|
||||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||||
bodyArg := call.Arguments[1].Export()
|
bodyArg := call.Arguments[1].Export()
|
||||||
@@ -145,7 +137,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
|||||||
case string:
|
case string:
|
||||||
bodyStr = v
|
bodyStr = v
|
||||||
case map[string]interface{}, []interface{}:
|
case map[string]interface{}, []interface{}:
|
||||||
// Auto-stringify objects and arrays to JSON
|
|
||||||
jsonBytes, err := json.Marshal(v)
|
jsonBytes, err := json.Marshal(v)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -154,12 +145,10 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
|||||||
}
|
}
|
||||||
bodyStr = string(jsonBytes)
|
bodyStr = string(jsonBytes)
|
||||||
default:
|
default:
|
||||||
// Fallback to string conversion
|
|
||||||
bodyStr = call.Arguments[1].String()
|
bodyStr = call.Arguments[1].String()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get headers if provided
|
|
||||||
headers := make(map[string]string)
|
headers := make(map[string]string)
|
||||||
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
|
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
|
||||||
headersObj := call.Arguments[2].Export()
|
headersObj := call.Arguments[2].Export()
|
||||||
@@ -177,11 +166,10 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set headers - user headers first
|
|
||||||
for k, v := range headers {
|
for k, v := range headers {
|
||||||
req.Header.Set(k, v)
|
req.Header.Set(k, v)
|
||||||
}
|
}
|
||||||
// Only set defaults if not provided by extension
|
|
||||||
if req.Header.Get("User-Agent") == "" {
|
if req.Header.Get("User-Agent") == "" {
|
||||||
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
|
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
|
||||||
}
|
}
|
||||||
@@ -189,7 +177,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
|||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute request
|
|
||||||
resp, err := r.httpClient.Do(req)
|
resp, err := r.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -205,19 +192,18 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract response headers - return all values as arrays for multi-value headers
|
|
||||||
respHeaders := make(map[string]interface{})
|
respHeaders := make(map[string]interface{})
|
||||||
for k, v := range resp.Header {
|
for k, v := range resp.Header {
|
||||||
if len(v) == 1 {
|
if len(v) == 1 {
|
||||||
respHeaders[k] = v[0]
|
respHeaders[k] = v[0]
|
||||||
} else {
|
} else {
|
||||||
respHeaders[k] = v // Return as array if multiple values
|
respHeaders[k] = v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"statusCode": resp.StatusCode,
|
"statusCode": resp.StatusCode,
|
||||||
"status": resp.StatusCode, // Alias for convenience
|
"status": resp.StatusCode,
|
||||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||||
"body": string(body),
|
"body": string(body),
|
||||||
"headers": respHeaders,
|
"headers": respHeaders,
|
||||||
@@ -240,27 +226,22 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default options
|
|
||||||
method := "GET"
|
method := "GET"
|
||||||
var bodyStr string
|
var bodyStr string
|
||||||
headers := make(map[string]string)
|
headers := make(map[string]string)
|
||||||
|
|
||||||
// Parse options if provided
|
|
||||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||||
optionsObj := call.Arguments[1].Export()
|
optionsObj := call.Arguments[1].Export()
|
||||||
if opts, ok := optionsObj.(map[string]interface{}); ok {
|
if opts, ok := optionsObj.(map[string]interface{}); ok {
|
||||||
// Get method
|
|
||||||
if m, ok := opts["method"].(string); ok {
|
if m, ok := opts["method"].(string); ok {
|
||||||
method = strings.ToUpper(m)
|
method = strings.ToUpper(m)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get body - support both string and object
|
|
||||||
if bodyArg, ok := opts["body"]; ok && bodyArg != nil {
|
if bodyArg, ok := opts["body"]; ok && bodyArg != nil {
|
||||||
switch v := bodyArg.(type) {
|
switch v := bodyArg.(type) {
|
||||||
case string:
|
case string:
|
||||||
bodyStr = v
|
bodyStr = v
|
||||||
case map[string]interface{}, []interface{}:
|
case map[string]interface{}, []interface{}:
|
||||||
// Auto-stringify objects and arrays to JSON
|
|
||||||
jsonBytes, err := json.Marshal(v)
|
jsonBytes, err := json.Marshal(v)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -273,7 +254,6 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get headers
|
|
||||||
if h, ok := opts["headers"].(map[string]interface{}); ok {
|
if h, ok := opts["headers"].(map[string]interface{}); ok {
|
||||||
for k, v := range h {
|
for k, v := range h {
|
||||||
headers[k] = fmt.Sprintf("%v", v)
|
headers[k] = fmt.Sprintf("%v", v)
|
||||||
@@ -282,7 +262,6 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create request
|
|
||||||
var reqBody io.Reader
|
var reqBody io.Reader
|
||||||
if bodyStr != "" {
|
if bodyStr != "" {
|
||||||
reqBody = strings.NewReader(bodyStr)
|
reqBody = strings.NewReader(bodyStr)
|
||||||
@@ -295,11 +274,10 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set headers - user headers first
|
|
||||||
for k, v := range headers {
|
for k, v := range headers {
|
||||||
req.Header.Set(k, v)
|
req.Header.Set(k, v)
|
||||||
}
|
}
|
||||||
// Only set defaults if not provided by extension
|
|
||||||
if req.Header.Get("User-Agent") == "" {
|
if req.Header.Get("User-Agent") == "" {
|
||||||
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
|
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
|
||||||
}
|
}
|
||||||
@@ -307,7 +285,6 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
|||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute request
|
|
||||||
resp, err := r.httpClient.Do(req)
|
resp, err := r.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -323,20 +300,18 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract response headers - return all values as arrays for multi-value headers
|
|
||||||
respHeaders := make(map[string]interface{})
|
respHeaders := make(map[string]interface{})
|
||||||
for k, v := range resp.Header {
|
for k, v := range resp.Header {
|
||||||
if len(v) == 1 {
|
if len(v) == 1 {
|
||||||
respHeaders[k] = v[0]
|
respHeaders[k] = v[0]
|
||||||
} else {
|
} else {
|
||||||
respHeaders[k] = v // Return as array if multiple values
|
respHeaders[k] = v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return response with helper properties
|
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"statusCode": resp.StatusCode,
|
"statusCode": resp.StatusCode,
|
||||||
"status": resp.StatusCode, // Alias for convenience
|
"status": resp.StatusCode,
|
||||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||||
"body": string(body),
|
"body": string(body),
|
||||||
"headers": respHeaders,
|
"headers": respHeaders,
|
||||||
@@ -347,7 +322,6 @@ func (r *ExtensionRuntime) httpPut(call goja.FunctionCall) goja.Value {
|
|||||||
return r.httpMethodShortcut("PUT", call)
|
return r.httpMethodShortcut("PUT", call)
|
||||||
}
|
}
|
||||||
|
|
||||||
// httpDelete performs a DELETE request (shortcut for http.request with method: "DELETE")
|
|
||||||
func (r *ExtensionRuntime) httpDelete(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) httpDelete(call goja.FunctionCall) goja.Value {
|
||||||
return r.httpMethodShortcut("DELETE", call)
|
return r.httpMethodShortcut("DELETE", call)
|
||||||
}
|
}
|
||||||
@@ -356,8 +330,6 @@ func (r *ExtensionRuntime) httpPatch(call goja.FunctionCall) goja.Value {
|
|||||||
return r.httpMethodShortcut("PATCH", call)
|
return r.httpMethodShortcut("PATCH", call)
|
||||||
}
|
}
|
||||||
|
|
||||||
// httpMethodShortcut is a helper for PUT/DELETE/PATCH shortcuts
|
|
||||||
// Signature: http.put(url, body, headers) / http.delete(url, headers) / http.patch(url, body, headers)
|
|
||||||
func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -377,9 +349,7 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
|
|||||||
var bodyStr string
|
var bodyStr string
|
||||||
headers := make(map[string]string)
|
headers := make(map[string]string)
|
||||||
|
|
||||||
// For DELETE, second arg is headers; for PUT/PATCH, second arg is body
|
|
||||||
if method == "DELETE" {
|
if method == "DELETE" {
|
||||||
// http.delete(url, headers)
|
|
||||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||||
headersObj := call.Arguments[1].Export()
|
headersObj := call.Arguments[1].Export()
|
||||||
if h, ok := headersObj.(map[string]interface{}); ok {
|
if h, ok := headersObj.(map[string]interface{}); ok {
|
||||||
@@ -389,7 +359,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// http.put(url, body, headers) / http.patch(url, body, headers)
|
|
||||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||||
bodyArg := call.Arguments[1].Export()
|
bodyArg := call.Arguments[1].Export()
|
||||||
switch v := bodyArg.(type) {
|
switch v := bodyArg.(type) {
|
||||||
@@ -418,7 +387,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create request
|
|
||||||
var reqBody io.Reader
|
var reqBody io.Reader
|
||||||
if bodyStr != "" {
|
if bodyStr != "" {
|
||||||
reqBody = strings.NewReader(bodyStr)
|
reqBody = strings.NewReader(bodyStr)
|
||||||
@@ -431,7 +399,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set headers - user headers first
|
|
||||||
for k, v := range headers {
|
for k, v := range headers {
|
||||||
req.Header.Set(k, v)
|
req.Header.Set(k, v)
|
||||||
}
|
}
|
||||||
@@ -442,7 +409,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
|
|||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute request
|
|
||||||
resp, err := r.httpClient.Do(req)
|
resp, err := r.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -458,7 +424,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract response headers
|
|
||||||
respHeaders := make(map[string]interface{})
|
respHeaders := make(map[string]interface{})
|
||||||
for k, v := range resp.Header {
|
for k, v := range resp.Header {
|
||||||
if len(v) == 1 {
|
if len(v) == 1 {
|
||||||
|
|||||||
@@ -17,12 +17,10 @@ import (
|
|||||||
|
|
||||||
// ==================== Storage API ====================
|
// ==================== Storage API ====================
|
||||||
|
|
||||||
// getStoragePath returns the path to the extension's storage file
|
|
||||||
func (r *ExtensionRuntime) getStoragePath() string {
|
func (r *ExtensionRuntime) getStoragePath() string {
|
||||||
return filepath.Join(r.dataDir, "storage.json")
|
return filepath.Join(r.dataDir, "storage.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadStorage loads the storage data from disk
|
|
||||||
func (r *ExtensionRuntime) loadStorage() (map[string]interface{}, error) {
|
func (r *ExtensionRuntime) loadStorage() (map[string]interface{}, error) {
|
||||||
storagePath := r.getStoragePath()
|
storagePath := r.getStoragePath()
|
||||||
data, err := os.ReadFile(storagePath)
|
data, err := os.ReadFile(storagePath)
|
||||||
@@ -41,7 +39,6 @@ func (r *ExtensionRuntime) loadStorage() (map[string]interface{}, error) {
|
|||||||
return storage, nil
|
return storage, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// saveStorage saves the storage data to disk
|
|
||||||
func (r *ExtensionRuntime) saveStorage(storage map[string]interface{}) error {
|
func (r *ExtensionRuntime) saveStorage(storage map[string]interface{}) error {
|
||||||
storagePath := r.getStoragePath()
|
storagePath := r.getStoragePath()
|
||||||
data, err := json.MarshalIndent(storage, "", " ")
|
data, err := json.MarshalIndent(storage, "", " ")
|
||||||
@@ -52,7 +49,6 @@ func (r *ExtensionRuntime) saveStorage(storage map[string]interface{}) error {
|
|||||||
return os.WriteFile(storagePath, data, 0644)
|
return os.WriteFile(storagePath, data, 0644)
|
||||||
}
|
}
|
||||||
|
|
||||||
// storageGet retrieves a value from storage
|
|
||||||
func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return goja.Undefined()
|
return goja.Undefined()
|
||||||
@@ -68,7 +64,6 @@ func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
|
|||||||
|
|
||||||
value, exists := storage[key]
|
value, exists := storage[key]
|
||||||
if !exists {
|
if !exists {
|
||||||
// Return default value if provided
|
|
||||||
if len(call.Arguments) > 1 {
|
if len(call.Arguments) > 1 {
|
||||||
return call.Arguments[1]
|
return call.Arguments[1]
|
||||||
}
|
}
|
||||||
@@ -78,7 +73,6 @@ func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(value)
|
return r.vm.ToValue(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// storageSet stores a value in storage
|
|
||||||
func (r *ExtensionRuntime) storageSet(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) storageSet(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue(false)
|
return r.vm.ToValue(false)
|
||||||
@@ -103,7 +97,6 @@ func (r *ExtensionRuntime) storageSet(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(true)
|
return r.vm.ToValue(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// storageRemove removes a value from storage
|
|
||||||
func (r *ExtensionRuntime) storageRemove(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) storageRemove(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(false)
|
return r.vm.ToValue(false)
|
||||||
@@ -127,19 +120,14 @@ func (r *ExtensionRuntime) storageRemove(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(true)
|
return r.vm.ToValue(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Credentials API (Encrypted Storage) ====================
|
|
||||||
|
|
||||||
// getCredentialsPath returns the path to the extension's encrypted credentials file
|
|
||||||
func (r *ExtensionRuntime) getCredentialsPath() string {
|
func (r *ExtensionRuntime) getCredentialsPath() string {
|
||||||
return filepath.Join(r.dataDir, ".credentials.enc")
|
return filepath.Join(r.dataDir, ".credentials.enc")
|
||||||
}
|
}
|
||||||
|
|
||||||
// getSaltPath returns the path to the extension's encryption salt file
|
|
||||||
func (r *ExtensionRuntime) getSaltPath() string {
|
func (r *ExtensionRuntime) getSaltPath() string {
|
||||||
return filepath.Join(r.dataDir, ".cred_salt")
|
return filepath.Join(r.dataDir, ".cred_salt")
|
||||||
}
|
}
|
||||||
|
|
||||||
// getOrCreateSalt gets existing salt or creates a new random one
|
|
||||||
func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) {
|
func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) {
|
||||||
saltPath := r.getSaltPath()
|
saltPath := r.getSaltPath()
|
||||||
|
|
||||||
@@ -160,22 +148,17 @@ func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) {
|
|||||||
return salt, nil
|
return salt, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getEncryptionKey derives an encryption key from extension ID + random salt
|
|
||||||
func (r *ExtensionRuntime) getEncryptionKey() ([]byte, error) {
|
func (r *ExtensionRuntime) getEncryptionKey() ([]byte, error) {
|
||||||
// Get or create per-installation random salt
|
|
||||||
salt, err := r.getOrCreateSalt()
|
salt, err := r.getOrCreateSalt()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Combine extension ID + random salt for key derivation
|
|
||||||
// This makes each installation unique, preventing mass decryption attacks
|
|
||||||
combined := append([]byte(r.extensionID), salt...)
|
combined := append([]byte(r.extensionID), salt...)
|
||||||
hash := sha256.Sum256(combined)
|
hash := sha256.Sum256(combined)
|
||||||
return hash[:], nil
|
return hash[:], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadCredentials loads and decrypts credentials from disk
|
|
||||||
func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) {
|
func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) {
|
||||||
credPath := r.getCredentialsPath()
|
credPath := r.getCredentialsPath()
|
||||||
data, err := os.ReadFile(credPath)
|
data, err := os.ReadFile(credPath)
|
||||||
@@ -186,7 +169,6 @@ func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decrypt the data
|
|
||||||
key, err := r.getEncryptionKey()
|
key, err := r.getEncryptionKey()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get encryption key: %w", err)
|
return nil, fmt.Errorf("failed to get encryption key: %w", err)
|
||||||
@@ -204,7 +186,6 @@ func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) {
|
|||||||
return creds, nil
|
return creds, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// saveCredentials encrypts and saves credentials to disk
|
|
||||||
func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error {
|
func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error {
|
||||||
data, err := json.Marshal(creds)
|
data, err := json.Marshal(creds)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -221,10 +202,9 @@ func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
credPath := r.getCredentialsPath()
|
credPath := r.getCredentialsPath()
|
||||||
return os.WriteFile(credPath, encrypted, 0600) // Restrictive permissions
|
return os.WriteFile(credPath, encrypted, 0600)
|
||||||
}
|
}
|
||||||
|
|
||||||
// credentialsStore stores an encrypted credential
|
|
||||||
func (r *ExtensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -260,7 +240,6 @@ func (r *ExtensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// credentialsGet retrieves a decrypted credential
|
|
||||||
func (r *ExtensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return goja.Undefined()
|
return goja.Undefined()
|
||||||
@@ -276,7 +255,6 @@ func (r *ExtensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
|
|||||||
|
|
||||||
value, exists := creds[key]
|
value, exists := creds[key]
|
||||||
if !exists {
|
if !exists {
|
||||||
// Return default value if provided
|
|
||||||
if len(call.Arguments) > 1 {
|
if len(call.Arguments) > 1 {
|
||||||
return call.Arguments[1]
|
return call.Arguments[1]
|
||||||
}
|
}
|
||||||
@@ -286,7 +264,6 @@ func (r *ExtensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(value)
|
return r.vm.ToValue(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// credentialsRemove removes a credential
|
|
||||||
func (r *ExtensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(false)
|
return r.vm.ToValue(false)
|
||||||
@@ -310,7 +287,6 @@ func (r *ExtensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value
|
|||||||
return r.vm.ToValue(true)
|
return r.vm.ToValue(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// credentialsHas checks if a credential exists
|
|
||||||
func (r *ExtensionRuntime) credentialsHas(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) credentialsHas(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(false)
|
return r.vm.ToValue(false)
|
||||||
@@ -327,9 +303,6 @@ func (r *ExtensionRuntime) credentialsHas(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(exists)
|
return r.vm.ToValue(exists)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Crypto Utilities ====================
|
|
||||||
|
|
||||||
// encryptAES encrypts data using AES-GCM
|
|
||||||
func encryptAES(plaintext []byte, key []byte) ([]byte, error) {
|
func encryptAES(plaintext []byte, key []byte) ([]byte, error) {
|
||||||
block, err := aes.NewCipher(key)
|
block, err := aes.NewCipher(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -350,7 +323,6 @@ func encryptAES(plaintext []byte, key []byte) ([]byte, error) {
|
|||||||
return ciphertext, nil
|
return ciphertext, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// decryptAES decrypts data using AES-GCM
|
|
||||||
func decryptAES(ciphertext []byte, key []byte) ([]byte, error) {
|
func decryptAES(ciphertext []byte, key []byte) ([]byte, error) {
|
||||||
block, err := aes.NewCipher(key)
|
block, err := aes.NewCipher(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import (
|
|||||||
|
|
||||||
// ==================== Utility Functions ====================
|
// ==================== Utility Functions ====================
|
||||||
|
|
||||||
// base64Encode encodes a string to base64
|
|
||||||
func (r *ExtensionRuntime) base64Encode(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) base64Encode(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue("")
|
return r.vm.ToValue("")
|
||||||
@@ -28,7 +27,6 @@ func (r *ExtensionRuntime) base64Encode(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input)))
|
return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// base64Decode decodes a base64 string
|
|
||||||
func (r *ExtensionRuntime) base64Decode(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) base64Decode(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue("")
|
return r.vm.ToValue("")
|
||||||
@@ -41,7 +39,6 @@ func (r *ExtensionRuntime) base64Decode(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(string(decoded))
|
return r.vm.ToValue(string(decoded))
|
||||||
}
|
}
|
||||||
|
|
||||||
// md5Hash computes MD5 hash of a string
|
|
||||||
func (r *ExtensionRuntime) md5Hash(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) md5Hash(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue("")
|
return r.vm.ToValue("")
|
||||||
@@ -51,7 +48,6 @@ func (r *ExtensionRuntime) md5Hash(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(hex.EncodeToString(hash[:]))
|
return r.vm.ToValue(hex.EncodeToString(hash[:]))
|
||||||
}
|
}
|
||||||
|
|
||||||
// sha256Hash computes SHA256 hash of a string
|
|
||||||
func (r *ExtensionRuntime) sha256Hash(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) sha256Hash(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue("")
|
return r.vm.ToValue("")
|
||||||
@@ -61,7 +57,6 @@ func (r *ExtensionRuntime) sha256Hash(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(hex.EncodeToString(hash[:]))
|
return r.vm.ToValue(hex.EncodeToString(hash[:]))
|
||||||
}
|
}
|
||||||
|
|
||||||
// hmacSHA256 computes HMAC-SHA256 of a message with a key
|
|
||||||
func (r *ExtensionRuntime) hmacSHA256(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) hmacSHA256(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue("")
|
return r.vm.ToValue("")
|
||||||
@@ -74,7 +69,6 @@ func (r *ExtensionRuntime) hmacSHA256(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(hex.EncodeToString(mac.Sum(nil)))
|
return r.vm.ToValue(hex.EncodeToString(mac.Sum(nil)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// hmacSHA256Base64 computes HMAC-SHA256 and returns base64 encoded result
|
|
||||||
func (r *ExtensionRuntime) hmacSHA256Base64(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) hmacSHA256Base64(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue("")
|
return r.vm.ToValue("")
|
||||||
@@ -87,9 +81,6 @@ func (r *ExtensionRuntime) hmacSHA256Base64(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(base64.StdEncoding.EncodeToString(mac.Sum(nil)))
|
return r.vm.ToValue(base64.StdEncoding.EncodeToString(mac.Sum(nil)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// hmacSHA1 computes HMAC-SHA1 of a message with a key (for TOTP)
|
|
||||||
// Arguments: message (string or array of bytes), key (string or array of bytes)
|
|
||||||
// Returns: array of bytes (for TOTP dynamic truncation)
|
|
||||||
func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue([]byte{})
|
return r.vm.ToValue([]byte{})
|
||||||
@@ -142,7 +133,6 @@ func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(jsArray)
|
return r.vm.ToValue(jsArray)
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseJSON parses a JSON string
|
|
||||||
func (r *ExtensionRuntime) parseJSON(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) parseJSON(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return goja.Undefined()
|
return goja.Undefined()
|
||||||
@@ -158,7 +148,6 @@ func (r *ExtensionRuntime) parseJSON(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(result)
|
return r.vm.ToValue(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// stringifyJSON converts a value to JSON string
|
|
||||||
func (r *ExtensionRuntime) stringifyJSON(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) stringifyJSON(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue("")
|
return r.vm.ToValue("")
|
||||||
@@ -174,9 +163,6 @@ func (r *ExtensionRuntime) stringifyJSON(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(string(data))
|
return r.vm.ToValue(string(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Crypto Utilities for Extensions ====================
|
|
||||||
|
|
||||||
// cryptoEncrypt encrypts a string using AES-GCM (for extension use)
|
|
||||||
func (r *ExtensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -188,7 +174,6 @@ func (r *ExtensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value {
|
|||||||
plaintext := call.Arguments[0].String()
|
plaintext := call.Arguments[0].String()
|
||||||
keyStr := call.Arguments[1].String()
|
keyStr := call.Arguments[1].String()
|
||||||
|
|
||||||
// Derive 32-byte key from provided key string
|
|
||||||
keyHash := sha256.Sum256([]byte(keyStr))
|
keyHash := sha256.Sum256([]byte(keyStr))
|
||||||
|
|
||||||
encrypted, err := encryptAES([]byte(plaintext), keyHash[:])
|
encrypted, err := encryptAES([]byte(plaintext), keyHash[:])
|
||||||
@@ -205,7 +190,6 @@ func (r *ExtensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// cryptoDecrypt decrypts a string using AES-GCM (for extension use)
|
|
||||||
func (r *ExtensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -225,14 +209,13 @@ func (r *ExtensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Derive 32-byte key from provided key string
|
|
||||||
keyHash := sha256.Sum256([]byte(keyStr))
|
keyHash := sha256.Sum256([]byte(keyStr))
|
||||||
|
|
||||||
decrypted, err := decryptAES(ciphertext, keyHash[:])
|
decrypted, err := decryptAES(ciphertext, keyHash[:])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
"error": err.Error(),
|
"error": "invalid base64 ciphertext",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,9 +225,8 @@ func (r *ExtensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// cryptoGenerateKey generates a random encryption key
|
|
||||||
func (r *ExtensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value {
|
||||||
length := 32 // Default 256-bit key
|
length := 32
|
||||||
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
||||||
if l, ok := call.Arguments[0].Export().(float64); ok {
|
if l, ok := call.Arguments[0].Export().(float64); ok {
|
||||||
length = int(l)
|
length = int(l)
|
||||||
@@ -266,13 +248,10 @@ func (r *ExtensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// randomUserAgent returns a random Chrome User-Agent string
|
|
||||||
func (r *ExtensionRuntime) randomUserAgent(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) randomUserAgent(call goja.FunctionCall) goja.Value {
|
||||||
return r.vm.ToValue(getRandomUserAgent())
|
return r.vm.ToValue(getRandomUserAgent())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Logging Functions ====================
|
|
||||||
|
|
||||||
func (r *ExtensionRuntime) logDebug(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) logDebug(call goja.FunctionCall) goja.Value {
|
||||||
msg := r.formatLogArgs(call.Arguments)
|
msg := r.formatLogArgs(call.Arguments)
|
||||||
GoLog("[Extension:%s:DEBUG] %s\n", r.extensionID, msg)
|
GoLog("[Extension:%s:DEBUG] %s\n", r.extensionID, msg)
|
||||||
@@ -305,8 +284,6 @@ func (r *ExtensionRuntime) formatLogArgs(args []goja.Value) string {
|
|||||||
return strings.Join(parts, " ")
|
return strings.Join(parts, " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Go Backend Wrappers ====================
|
|
||||||
|
|
||||||
func (r *ExtensionRuntime) sanitizeFilenameWrapper(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) sanitizeFilenameWrapper(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue("")
|
return r.vm.ToValue("")
|
||||||
@@ -315,7 +292,6 @@ func (r *ExtensionRuntime) sanitizeFilenameWrapper(call goja.FunctionCall) goja.
|
|||||||
return r.vm.ToValue(sanitizeFilename(input))
|
return r.vm.ToValue(sanitizeFilename(input))
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterGoBackendAPIs adds more Go backend functions to the VM
|
|
||||||
func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
|
func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
|
||||||
gobackendObj := vm.Get("gobackend")
|
gobackendObj := vm.Get("gobackend")
|
||||||
if gobackendObj == nil || goja.IsUndefined(gobackendObj) {
|
if gobackendObj == nil || goja.IsUndefined(gobackendObj) {
|
||||||
@@ -325,7 +301,6 @@ func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
|
|||||||
|
|
||||||
obj := gobackendObj.(*goja.Object)
|
obj := gobackendObj.(*goja.Object)
|
||||||
|
|
||||||
// Expose sanitizeFilename
|
|
||||||
obj.Set("sanitizeFilename", func(call goja.FunctionCall) goja.Value {
|
obj.Set("sanitizeFilename", func(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return vm.ToValue("")
|
return vm.ToValue("")
|
||||||
@@ -333,7 +308,6 @@ func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
|
|||||||
return vm.ToValue(sanitizeFilename(call.Arguments[0].String()))
|
return vm.ToValue(sanitizeFilename(call.Arguments[0].String()))
|
||||||
})
|
})
|
||||||
|
|
||||||
// Expose getAudioQuality
|
|
||||||
obj.Set("getAudioQuality", func(call goja.FunctionCall) goja.Value {
|
obj.Set("getAudioQuality", func(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return vm.ToValue(map[string]interface{}{
|
return vm.ToValue(map[string]interface{}{
|
||||||
@@ -356,7 +330,6 @@ func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Expose buildFilename
|
|
||||||
obj.Set("buildFilename", func(call goja.FunctionCall) goja.Value {
|
obj.Set("buildFilename", func(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return vm.ToValue("")
|
return vm.ToValue("")
|
||||||
@@ -373,7 +346,6 @@ func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
|
|||||||
return vm.ToValue(buildFilenameFromTemplate(template, metadata))
|
return vm.ToValue(buildFilenameFromTemplate(template, metadata))
|
||||||
})
|
})
|
||||||
|
|
||||||
// Expose getLocalTime - returns device local time info
|
|
||||||
obj.Set("getLocalTime", func(call goja.FunctionCall) goja.Value {
|
obj.Set("getLocalTime", func(call goja.FunctionCall) goja.Value {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
_, offsetSeconds := now.Zone()
|
_, offsetSeconds := now.Zone()
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ExtensionSettingsStore manages settings for all extensions
|
|
||||||
type ExtensionSettingsStore struct {
|
type ExtensionSettingsStore struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
dataDir string
|
dataDir string
|
||||||
@@ -22,7 +21,6 @@ var (
|
|||||||
globalSettingsStoreOnce sync.Once
|
globalSettingsStoreOnce sync.Once
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetExtensionSettingsStore returns the global settings store
|
|
||||||
func GetExtensionSettingsStore() *ExtensionSettingsStore {
|
func GetExtensionSettingsStore() *ExtensionSettingsStore {
|
||||||
globalSettingsStoreOnce.Do(func() {
|
globalSettingsStoreOnce.Do(func() {
|
||||||
globalSettingsStore = &ExtensionSettingsStore{
|
globalSettingsStore = &ExtensionSettingsStore{
|
||||||
@@ -32,7 +30,6 @@ func GetExtensionSettingsStore() *ExtensionSettingsStore {
|
|||||||
return globalSettingsStore
|
return globalSettingsStore
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetDataDir sets the data directory for settings storage
|
|
||||||
func (s *ExtensionSettingsStore) SetDataDir(dataDir string) error {
|
func (s *ExtensionSettingsStore) SetDataDir(dataDir string) error {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
@@ -45,12 +42,10 @@ func (s *ExtensionSettingsStore) SetDataDir(dataDir string) error {
|
|||||||
return s.loadAllSettings()
|
return s.loadAllSettings()
|
||||||
}
|
}
|
||||||
|
|
||||||
// getSettingsPath returns the path to an extension's settings file
|
|
||||||
func (s *ExtensionSettingsStore) getSettingsPath(extensionID string) string {
|
func (s *ExtensionSettingsStore) getSettingsPath(extensionID string) string {
|
||||||
return filepath.Join(s.dataDir, extensionID, "settings.json")
|
return filepath.Join(s.dataDir, extensionID, "settings.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadAllSettings loads settings for all extensions from disk
|
|
||||||
func (s *ExtensionSettingsStore) loadAllSettings() error {
|
func (s *ExtensionSettingsStore) loadAllSettings() error {
|
||||||
entries, err := os.ReadDir(s.dataDir)
|
entries, err := os.ReadDir(s.dataDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -75,7 +70,6 @@ func (s *ExtensionSettingsStore) loadAllSettings() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadSettings loads settings for a specific extension
|
|
||||||
func (s *ExtensionSettingsStore) loadSettings(extensionID string) (map[string]interface{}, error) {
|
func (s *ExtensionSettingsStore) loadSettings(extensionID string) (map[string]interface{}, error) {
|
||||||
settingsPath := s.getSettingsPath(extensionID)
|
settingsPath := s.getSettingsPath(extensionID)
|
||||||
data, err := os.ReadFile(settingsPath)
|
data, err := os.ReadFile(settingsPath)
|
||||||
@@ -94,7 +88,6 @@ func (s *ExtensionSettingsStore) loadSettings(extensionID string) (map[string]in
|
|||||||
return settings, nil
|
return settings, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// saveSettings saves settings for a specific extension
|
|
||||||
func (s *ExtensionSettingsStore) saveSettings(extensionID string, settings map[string]interface{}) error {
|
func (s *ExtensionSettingsStore) saveSettings(extensionID string, settings map[string]interface{}) error {
|
||||||
settingsPath := s.getSettingsPath(extensionID)
|
settingsPath := s.getSettingsPath(extensionID)
|
||||||
|
|
||||||
@@ -111,8 +104,6 @@ func (s *ExtensionSettingsStore) saveSettings(extensionID string, settings map[s
|
|||||||
return os.WriteFile(settingsPath, data, 0644)
|
return os.WriteFile(settingsPath, data, 0644)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get retrieves a setting value for an extension
|
|
||||||
// Returns error if extension or key not found (gomobile compatible)
|
|
||||||
func (s *ExtensionSettingsStore) Get(extensionID, key string) (interface{}, error) {
|
func (s *ExtensionSettingsStore) Get(extensionID, key string) (interface{}, error) {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
@@ -129,7 +120,6 @@ func (s *ExtensionSettingsStore) Get(extensionID, key string) (interface{}, erro
|
|||||||
return value, nil
|
return value, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAll retrieves all settings for an extension
|
|
||||||
func (s *ExtensionSettingsStore) GetAll(extensionID string) map[string]interface{} {
|
func (s *ExtensionSettingsStore) GetAll(extensionID string) map[string]interface{} {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
@@ -147,7 +137,6 @@ func (s *ExtensionSettingsStore) GetAll(extensionID string) map[string]interface
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set stores a setting value for an extension
|
|
||||||
func (s *ExtensionSettingsStore) Set(extensionID, key string, value interface{}) error {
|
func (s *ExtensionSettingsStore) Set(extensionID, key string, value interface{}) error {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
@@ -161,7 +150,6 @@ func (s *ExtensionSettingsStore) Set(extensionID, key string, value interface{})
|
|||||||
return s.saveSettings(extensionID, s.settings[extensionID])
|
return s.saveSettings(extensionID, s.settings[extensionID])
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetAll stores all settings for an extension
|
|
||||||
func (s *ExtensionSettingsStore) SetAll(extensionID string, settings map[string]interface{}) error {
|
func (s *ExtensionSettingsStore) SetAll(extensionID string, settings map[string]interface{}) error {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
@@ -172,7 +160,6 @@ func (s *ExtensionSettingsStore) SetAll(extensionID string, settings map[string]
|
|||||||
return s.saveSettings(extensionID, settings)
|
return s.saveSettings(extensionID, settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove removes a setting for an extension
|
|
||||||
func (s *ExtensionSettingsStore) Remove(extensionID, key string) error {
|
func (s *ExtensionSettingsStore) Remove(extensionID, key string) error {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
@@ -188,7 +175,6 @@ func (s *ExtensionSettingsStore) Remove(extensionID, key string) error {
|
|||||||
return s.saveSettings(extensionID, extSettings)
|
return s.saveSettings(extensionID, extSettings)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveAll removes all settings for an extension
|
|
||||||
func (s *ExtensionSettingsStore) RemoveAll(extensionID string) error {
|
func (s *ExtensionSettingsStore) RemoveAll(extensionID string) error {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
@@ -203,7 +189,6 @@ func (s *ExtensionSettingsStore) RemoveAll(extensionID string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAllExtensionSettings returns settings for all extensions as JSON
|
|
||||||
func (s *ExtensionSettingsStore) GetAllExtensionSettingsJSON() (string, error) {
|
func (s *ExtensionSettingsStore) GetAllExtensionSettingsJSON() (string, error) {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
|
|||||||
@@ -20,28 +20,26 @@ const (
|
|||||||
CategoryIntegration = "integration"
|
CategoryIntegration = "integration"
|
||||||
)
|
)
|
||||||
|
|
||||||
// StoreExtension represents an extension in the store
|
|
||||||
type StoreExtension struct {
|
type StoreExtension struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
DisplayName string `json:"display_name,omitempty"`
|
DisplayName string `json:"display_name,omitempty"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Author string `json:"author"`
|
Author string `json:"author"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
DownloadURL string `json:"download_url,omitempty"`
|
DownloadURL string `json:"download_url,omitempty"`
|
||||||
IconURL string `json:"icon_url,omitempty"`
|
IconURL string `json:"icon_url,omitempty"`
|
||||||
Category string `json:"category"`
|
Category string `json:"category"`
|
||||||
Tags []string `json:"tags,omitempty"`
|
Tags []string `json:"tags,omitempty"`
|
||||||
Downloads int `json:"downloads"`
|
Downloads int `json:"downloads"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
UpdatedAt string `json:"updated_at"`
|
||||||
MinAppVersion string `json:"min_app_version,omitempty"`
|
MinAppVersion string `json:"min_app_version,omitempty"`
|
||||||
DisplayNameAlt string `json:"displayName,omitempty"`
|
DisplayNameAlt string `json:"displayName,omitempty"`
|
||||||
DownloadURLAlt string `json:"downloadUrl,omitempty"`
|
DownloadURLAlt string `json:"downloadUrl,omitempty"`
|
||||||
IconURLAlt string `json:"iconUrl,omitempty"`
|
IconURLAlt string `json:"iconUrl,omitempty"`
|
||||||
MinAppVersionAlt string `json:"minAppVersion,omitempty"`
|
MinAppVersionAlt string `json:"minAppVersion,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// getDisplayName returns display name, falling back to name (private to avoid gomobile conflict)
|
|
||||||
func (e *StoreExtension) getDisplayName() string {
|
func (e *StoreExtension) getDisplayName() string {
|
||||||
if e.DisplayName != "" {
|
if e.DisplayName != "" {
|
||||||
return e.DisplayName
|
return e.DisplayName
|
||||||
@@ -52,7 +50,6 @@ func (e *StoreExtension) getDisplayName() string {
|
|||||||
return e.Name
|
return e.Name
|
||||||
}
|
}
|
||||||
|
|
||||||
// getDownloadURL returns download URL from either field (private to avoid gomobile conflict)
|
|
||||||
func (e *StoreExtension) getDownloadURL() string {
|
func (e *StoreExtension) getDownloadURL() string {
|
||||||
if e.DownloadURL != "" {
|
if e.DownloadURL != "" {
|
||||||
return e.DownloadURL
|
return e.DownloadURL
|
||||||
@@ -60,7 +57,6 @@ func (e *StoreExtension) getDownloadURL() string {
|
|||||||
return e.DownloadURLAlt
|
return e.DownloadURLAlt
|
||||||
}
|
}
|
||||||
|
|
||||||
// getIconURL returns icon URL from either field (private to avoid gomobile conflict)
|
|
||||||
func (e *StoreExtension) getIconURL() string {
|
func (e *StoreExtension) getIconURL() string {
|
||||||
if e.IconURL != "" {
|
if e.IconURL != "" {
|
||||||
return e.IconURL
|
return e.IconURL
|
||||||
@@ -68,7 +64,6 @@ func (e *StoreExtension) getIconURL() string {
|
|||||||
return e.IconURLAlt
|
return e.IconURLAlt
|
||||||
}
|
}
|
||||||
|
|
||||||
// getMinAppVersion returns min app version from either field (private to avoid gomobile conflict)
|
|
||||||
func (e *StoreExtension) getMinAppVersion() string {
|
func (e *StoreExtension) getMinAppVersion() string {
|
||||||
if e.MinAppVersion != "" {
|
if e.MinAppVersion != "" {
|
||||||
return e.MinAppVersion
|
return e.MinAppVersion
|
||||||
@@ -76,7 +71,6 @@ func (e *StoreExtension) getMinAppVersion() string {
|
|||||||
return e.MinAppVersionAlt
|
return e.MinAppVersionAlt
|
||||||
}
|
}
|
||||||
|
|
||||||
// StoreRegistry represents the extension registry
|
|
||||||
type StoreRegistry struct {
|
type StoreRegistry struct {
|
||||||
Version int `json:"version"`
|
Version int `json:"version"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
UpdatedAt string `json:"updated_at"`
|
||||||
@@ -103,7 +97,6 @@ type StoreExtensionResponse struct {
|
|||||||
HasUpdate bool `json:"has_update"`
|
HasUpdate bool `json:"has_update"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToResponse converts StoreExtension to normalized response
|
|
||||||
func (e *StoreExtension) ToResponse() StoreExtensionResponse {
|
func (e *StoreExtension) ToResponse() StoreExtensionResponse {
|
||||||
return StoreExtensionResponse{
|
return StoreExtensionResponse{
|
||||||
ID: e.ID,
|
ID: e.ID,
|
||||||
@@ -122,7 +115,6 @@ func (e *StoreExtension) ToResponse() StoreExtensionResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExtensionStore manages the extension store
|
|
||||||
type ExtensionStore struct {
|
type ExtensionStore struct {
|
||||||
registryURL string
|
registryURL string
|
||||||
cacheDir string
|
cacheDir string
|
||||||
@@ -143,7 +135,6 @@ const (
|
|||||||
cacheFileName = "store_cache.json"
|
cacheFileName = "store_cache.json"
|
||||||
)
|
)
|
||||||
|
|
||||||
// InitExtensionStore initializes the extension store
|
|
||||||
func InitExtensionStore(cacheDir string) *ExtensionStore {
|
func InitExtensionStore(cacheDir string) *ExtensionStore {
|
||||||
extensionStoreMu.Lock()
|
extensionStoreMu.Lock()
|
||||||
defer extensionStoreMu.Unlock()
|
defer extensionStoreMu.Unlock()
|
||||||
@@ -160,14 +151,12 @@ func InitExtensionStore(cacheDir string) *ExtensionStore {
|
|||||||
return extensionStore
|
return extensionStore
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetExtensionStore returns the singleton store instance
|
|
||||||
func GetExtensionStore() *ExtensionStore {
|
func GetExtensionStore() *ExtensionStore {
|
||||||
extensionStoreMu.Lock()
|
extensionStoreMu.Lock()
|
||||||
defer extensionStoreMu.Unlock()
|
defer extensionStoreMu.Unlock()
|
||||||
return extensionStore
|
return extensionStore
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadDiskCache loads cached registry from disk
|
|
||||||
func (s *ExtensionStore) loadDiskCache() {
|
func (s *ExtensionStore) loadDiskCache() {
|
||||||
if s.cacheDir == "" {
|
if s.cacheDir == "" {
|
||||||
return
|
return
|
||||||
@@ -193,7 +182,6 @@ func (s *ExtensionStore) loadDiskCache() {
|
|||||||
LogDebug("ExtensionStore", "Loaded %d extensions from disk cache", len(s.cache.Extensions))
|
LogDebug("ExtensionStore", "Loaded %d extensions from disk cache", len(s.cache.Extensions))
|
||||||
}
|
}
|
||||||
|
|
||||||
// saveDiskCache saves registry to disk cache
|
|
||||||
func (s *ExtensionStore) saveDiskCache() {
|
func (s *ExtensionStore) saveDiskCache() {
|
||||||
if s.cacheDir == "" || s.cache == nil {
|
if s.cacheDir == "" || s.cache == nil {
|
||||||
return
|
return
|
||||||
@@ -216,7 +204,6 @@ func (s *ExtensionStore) saveDiskCache() {
|
|||||||
os.WriteFile(cachePath, data, 0644)
|
os.WriteFile(cachePath, data, 0644)
|
||||||
}
|
}
|
||||||
|
|
||||||
// FetchRegistry fetches the extension registry from GitHub
|
|
||||||
func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error) {
|
func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error) {
|
||||||
s.cacheMu.Lock()
|
s.cacheMu.Lock()
|
||||||
defer s.cacheMu.Unlock()
|
defer s.cacheMu.Unlock()
|
||||||
@@ -267,7 +254,6 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error
|
|||||||
return ®istry, nil
|
return ®istry, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetExtensionsWithStatus returns extensions with installation status
|
|
||||||
func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, error) {
|
func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, error) {
|
||||||
registry, err := s.FetchRegistry(false)
|
registry, err := s.FetchRegistry(false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -299,7 +285,6 @@ func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, er
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DownloadExtension downloads an extension package to the specified path
|
|
||||||
func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string) error {
|
func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string) error {
|
||||||
registry, err := s.FetchRegistry(false)
|
registry, err := s.FetchRegistry(false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -347,7 +332,6 @@ func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCategories returns all available categories
|
|
||||||
func (s *ExtensionStore) GetCategories() []string {
|
func (s *ExtensionStore) GetCategories() []string {
|
||||||
return []string{
|
return []string{
|
||||||
CategoryMetadata,
|
CategoryMetadata,
|
||||||
@@ -358,7 +342,6 @@ func (s *ExtensionStore) GetCategories() []string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchExtensions searches extensions by query
|
|
||||||
func (s *ExtensionStore) SearchExtensions(query string, category string) ([]StoreExtensionResponse, error) {
|
func (s *ExtensionStore) SearchExtensions(query string, category string) ([]StoreExtensionResponse, error) {
|
||||||
extensions, err := s.GetExtensionsWithStatus()
|
extensions, err := s.GetExtensionsWithStatus()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -404,7 +387,6 @@ func (s *ExtensionStore) SearchExtensions(query string, category string) ([]Stor
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearCache clears the in-memory and disk cache
|
|
||||||
func (s *ExtensionStore) ClearCache() {
|
func (s *ExtensionStore) ClearCache() {
|
||||||
s.cacheMu.Lock()
|
s.cacheMu.Lock()
|
||||||
defer s.cacheMu.Unlock()
|
defer s.cacheMu.Unlock()
|
||||||
|
|||||||
+1
-27
@@ -15,9 +15,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// getRandomUserAgent generates a random Windows Chrome User-Agent string
|
|
||||||
// Uses modern Chrome format with build and patch numbers
|
|
||||||
// Windows 11 still reports as "Windows NT 10.0" for compatibility
|
|
||||||
func getRandomUserAgent() string {
|
func getRandomUserAgent() string {
|
||||||
// Chrome version 120-145 (modern range)
|
// Chrome version 120-145 (modern range)
|
||||||
chromeVersion := rand.Intn(26) + 120
|
chromeVersion := rand.Intn(26) + 120
|
||||||
@@ -38,10 +35,9 @@ const (
|
|||||||
SongLinkTimeout = 30 * time.Second
|
SongLinkTimeout = 30 * time.Second
|
||||||
DefaultMaxRetries = 3
|
DefaultMaxRetries = 3
|
||||||
DefaultRetryDelay = 1 * time.Second
|
DefaultRetryDelay = 1 * time.Second
|
||||||
Second = time.Second // Exported for use in other files
|
Second = time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
// Shared transport with connection pooling to prevent TCP exhaustion
|
|
||||||
var sharedTransport = &http.Transport{
|
var sharedTransport = &http.Transport{
|
||||||
DialContext: (&net.Dialer{
|
DialContext: (&net.Dialer{
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
@@ -85,7 +81,6 @@ func GetDownloadClient() *http.Client {
|
|||||||
return downloadClient
|
return downloadClient
|
||||||
}
|
}
|
||||||
|
|
||||||
// CloseIdleConnections closes idle connections in the shared transport
|
|
||||||
func CloseIdleConnections() {
|
func CloseIdleConnections() {
|
||||||
sharedTransport.CloseIdleConnections()
|
sharedTransport.CloseIdleConnections()
|
||||||
}
|
}
|
||||||
@@ -117,9 +112,6 @@ func DefaultRetryConfig() RetryConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DoRequestWithRetry executes an HTTP request with retry logic and exponential backoff
|
|
||||||
// Handles 429 (Too Many Requests) responses with Retry-After header
|
|
||||||
// Also detects and logs ISP blocking
|
|
||||||
func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConfig) (*http.Response, error) {
|
func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConfig) (*http.Response, error) {
|
||||||
var lastErr error
|
var lastErr error
|
||||||
delay := config.InitialDelay
|
delay := config.InitialDelay
|
||||||
@@ -149,12 +141,10 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Success
|
|
||||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle rate limiting (429)
|
|
||||||
if resp.StatusCode == 429 {
|
if resp.StatusCode == 429 {
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
retryAfter := getRetryAfterDuration(resp)
|
retryAfter := getRetryAfterDuration(resp)
|
||||||
@@ -194,7 +184,6 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server errors (5xx) - retry
|
|
||||||
if resp.StatusCode >= 500 {
|
if resp.StatusCode >= 500 {
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
lastErr = fmt.Errorf("server error: HTTP %d", resp.StatusCode)
|
lastErr = fmt.Errorf("server error: HTTP %d", resp.StatusCode)
|
||||||
@@ -206,7 +195,6 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Client errors (4xx except 429) - don't retry
|
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,12 +213,10 @@ func getRetryAfterDuration(resp *http.Response) time.Duration {
|
|||||||
return 60 * time.Second // Default wait time
|
return 60 * time.Second // Default wait time
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try parsing as seconds
|
|
||||||
if seconds, err := strconv.Atoi(retryAfter); err == nil {
|
if seconds, err := strconv.Atoi(retryAfter); err == nil {
|
||||||
return time.Duration(seconds) * time.Second
|
return time.Duration(seconds) * time.Second
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try parsing as HTTP date
|
|
||||||
if t, err := http.ParseTime(retryAfter); err == nil {
|
if t, err := http.ParseTime(retryAfter); err == nil {
|
||||||
duration := time.Until(t)
|
duration := time.Until(t)
|
||||||
if duration > 0 {
|
if duration > 0 {
|
||||||
@@ -241,8 +227,6 @@ func getRetryAfterDuration(resp *http.Response) time.Duration {
|
|||||||
return 60 * time.Second // Default
|
return 60 * time.Second // Default
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReadResponseBody reads and returns the response body
|
|
||||||
// Returns error if body is empty
|
|
||||||
func ReadResponseBody(resp *http.Response) ([]byte, error) {
|
func ReadResponseBody(resp *http.Response) ([]byte, error) {
|
||||||
if resp == nil {
|
if resp == nil {
|
||||||
return nil, fmt.Errorf("response is nil")
|
return nil, fmt.Errorf("response is nil")
|
||||||
@@ -272,14 +256,12 @@ func ValidateResponse(resp *http.Response) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildErrorMessage creates a detailed error message for API failures
|
|
||||||
func BuildErrorMessage(apiURL string, statusCode int, responsePreview string) string {
|
func BuildErrorMessage(apiURL string, statusCode int, responsePreview string) string {
|
||||||
msg := fmt.Sprintf("API %s failed", apiURL)
|
msg := fmt.Sprintf("API %s failed", apiURL)
|
||||||
if statusCode > 0 {
|
if statusCode > 0 {
|
||||||
msg += fmt.Sprintf(" (HTTP %d)", statusCode)
|
msg += fmt.Sprintf(" (HTTP %d)", statusCode)
|
||||||
}
|
}
|
||||||
if responsePreview != "" {
|
if responsePreview != "" {
|
||||||
// Truncate preview if too long
|
|
||||||
if len(responsePreview) > 100 {
|
if len(responsePreview) > 100 {
|
||||||
responsePreview = responsePreview[:100] + "..."
|
responsePreview = responsePreview[:100] + "..."
|
||||||
}
|
}
|
||||||
@@ -298,18 +280,14 @@ func (e *ISPBlockingError) Error() string {
|
|||||||
return fmt.Sprintf("ISP blocking detected for %s: %s", e.Domain, e.Reason)
|
return fmt.Sprintf("ISP blocking detected for %s: %s", e.Domain, e.Reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsISPBlocking checks if an error is likely caused by ISP blocking
|
|
||||||
// Returns the ISPBlockingError if detected, nil otherwise
|
|
||||||
func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
|
func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract domain from URL
|
|
||||||
domain := extractDomain(requestURL)
|
domain := extractDomain(requestURL)
|
||||||
errStr := strings.ToLower(err.Error())
|
errStr := strings.ToLower(err.Error())
|
||||||
|
|
||||||
// Check for DNS resolution failure (common ISP blocking method)
|
|
||||||
var dnsErr *net.DNSError
|
var dnsErr *net.DNSError
|
||||||
if errors.As(err, &dnsErr) {
|
if errors.As(err, &dnsErr) {
|
||||||
if dnsErr.IsNotFound || dnsErr.IsTemporary {
|
if dnsErr.IsNotFound || dnsErr.IsTemporary {
|
||||||
@@ -321,11 +299,9 @@ func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for connection refused (ISP firewall blocking)
|
|
||||||
var opErr *net.OpError
|
var opErr *net.OpError
|
||||||
if errors.As(err, &opErr) {
|
if errors.As(err, &opErr) {
|
||||||
if opErr.Op == "dial" {
|
if opErr.Op == "dial" {
|
||||||
// Check for specific syscall errors
|
|
||||||
var syscallErr syscall.Errno
|
var syscallErr syscall.Errno
|
||||||
if errors.As(opErr.Err, &syscallErr) {
|
if errors.As(opErr.Err, &syscallErr) {
|
||||||
switch syscallErr {
|
switch syscallErr {
|
||||||
@@ -364,7 +340,6 @@ func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for TLS handshake failure (ISP MITM or blocking HTTPS)
|
|
||||||
var tlsErr *tls.RecordHeaderError
|
var tlsErr *tls.RecordHeaderError
|
||||||
if errors.As(err, &tlsErr) {
|
if errors.As(err, &tlsErr) {
|
||||||
return &ISPBlockingError{
|
return &ISPBlockingError{
|
||||||
@@ -425,7 +400,6 @@ func extractDomain(rawURL string) string {
|
|||||||
|
|
||||||
parsed, err := url.Parse(rawURL)
|
parsed, err := url.Parse(rawURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Try to extract domain manually
|
|
||||||
rawURL = strings.TrimPrefix(rawURL, "https://")
|
rawURL = strings.TrimPrefix(rawURL, "https://")
|
||||||
rawURL = strings.TrimPrefix(rawURL, "http://")
|
rawURL = strings.TrimPrefix(rawURL, "http://")
|
||||||
if idx := strings.Index(rawURL, "/"); idx > 0 {
|
if idx := strings.Index(rawURL, "/"); idx > 0 {
|
||||||
|
|||||||
@@ -238,12 +238,9 @@ func (c *LyricsClient) durationMatches(lrcDuration, targetDuration float64) bool
|
|||||||
return diff <= durationToleranceSec
|
return diff <= durationToleranceSec
|
||||||
}
|
}
|
||||||
|
|
||||||
// durationSec: track duration in seconds for matching, use 0 to skip duration matching
|
|
||||||
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
|
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
|
||||||
// Normalize artist name - take first artist before comma/semicolon for better matching
|
|
||||||
primaryArtist := normalizeArtistName(artistName)
|
primaryArtist := normalizeArtistName(artistName)
|
||||||
|
|
||||||
// Check cache first (use original artist name for cache key)
|
|
||||||
if cached, found := globalLyricsCache.Get(artistName, trackName, durationSec); found {
|
if cached, found := globalLyricsCache.Get(artistName, trackName, durationSec); found {
|
||||||
fmt.Printf("[Lyrics] Cache hit for: %s - %s\n", artistName, trackName)
|
fmt.Printf("[Lyrics] Cache hit for: %s - %s\n", artistName, trackName)
|
||||||
cachedCopy := *cached
|
cachedCopy := *cached
|
||||||
@@ -254,12 +251,10 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
|
|||||||
var lyrics *LyricsResponse
|
var lyrics *LyricsResponse
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
// Helper to check if lyrics result is valid (has lines OR is instrumental)
|
|
||||||
isValidResult := func(l *LyricsResponse) bool {
|
isValidResult := func(l *LyricsResponse) bool {
|
||||||
return l != nil && (len(l.Lines) > 0 || l.Instrumental)
|
return l != nil && (len(l.Lines) > 0 || l.Instrumental)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try exact match first with primary artist
|
|
||||||
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, trackName)
|
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, trackName)
|
||||||
if err == nil && isValidResult(lyrics) {
|
if err == nil && isValidResult(lyrics) {
|
||||||
lyrics.Source = "LRCLIB"
|
lyrics.Source = "LRCLIB"
|
||||||
@@ -267,7 +262,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
|
|||||||
return lyrics, nil
|
return lyrics, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try with full artist name if different from primary
|
|
||||||
if primaryArtist != artistName {
|
if primaryArtist != artistName {
|
||||||
lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName)
|
lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName)
|
||||||
if err == nil && isValidResult(lyrics) {
|
if err == nil && isValidResult(lyrics) {
|
||||||
@@ -277,7 +271,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try with simplified track name
|
|
||||||
simplifiedTrack := simplifyTrackName(trackName)
|
simplifiedTrack := simplifyTrackName(trackName)
|
||||||
if simplifiedTrack != trackName {
|
if simplifiedTrack != trackName {
|
||||||
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, simplifiedTrack)
|
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, simplifiedTrack)
|
||||||
@@ -288,7 +281,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search with duration matching (use primary artist for search)
|
|
||||||
query := primaryArtist + " " + trackName
|
query := primaryArtist + " " + trackName
|
||||||
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
|
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
|
||||||
if err == nil && isValidResult(lyrics) {
|
if err == nil && isValidResult(lyrics) {
|
||||||
@@ -297,7 +289,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
|
|||||||
return lyrics, nil
|
return lyrics, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search with simplified name and duration matching
|
|
||||||
if simplifiedTrack != trackName {
|
if simplifiedTrack != trackName {
|
||||||
query = primaryArtist + " " + simplifiedTrack
|
query = primaryArtist + " " + simplifiedTrack
|
||||||
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
|
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
|
||||||
@@ -393,32 +384,6 @@ func msToLRCTimestamp(ms int64) string {
|
|||||||
return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds)
|
return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use convertToLRCWithMetadata for full LRC with headers
|
|
||||||
// Kept for potential future use
|
|
||||||
// func convertToLRC(lyrics *LyricsResponse) string {
|
|
||||||
// if lyrics == nil || len(lyrics.Lines) == 0 {
|
|
||||||
// return ""
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// var builder strings.Builder
|
|
||||||
//
|
|
||||||
// if lyrics.SyncType == "LINE_SYNCED" {
|
|
||||||
// for _, line := range lyrics.Lines {
|
|
||||||
// timestamp := msToLRCTimestamp(line.StartTimeMs)
|
|
||||||
// builder.WriteString(timestamp)
|
|
||||||
// builder.WriteString(line.Words)
|
|
||||||
// builder.WriteString("\n")
|
|
||||||
// }
|
|
||||||
// } else {
|
|
||||||
// for _, line := range lyrics.Lines {
|
|
||||||
// builder.WriteString(line.Words)
|
|
||||||
// builder.WriteString("\n")
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// return builder.String()
|
|
||||||
// }
|
|
||||||
|
|
||||||
func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName string) string {
|
func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName string) string {
|
||||||
if lyrics == nil || len(lyrics.Lines) == 0 {
|
if lyrics == nil || len(lyrics.Lines) == 0 {
|
||||||
return ""
|
return ""
|
||||||
@@ -480,11 +445,7 @@ func simplifyTrackName(name string) string {
|
|||||||
return strings.TrimSpace(result)
|
return strings.TrimSpace(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// normalizeArtistName extracts the primary artist from multi-artist strings
|
|
||||||
// e.g., "HOYO-MiX, AURORA" -> "HOYO-MiX"
|
|
||||||
// e.g., "Artist1; Artist2" -> "Artist1"
|
|
||||||
func normalizeArtistName(name string) string {
|
func normalizeArtistName(name string) string {
|
||||||
// Split by common separators: ", " or "; " or " & " or " feat. " or " ft. "
|
|
||||||
separators := []string{", ", "; ", " & ", " feat. ", " ft. ", " featuring ", " with "}
|
separators := []string{", ", "; ", " & ", " feat. ", " ft. ", " featuring ", " with "}
|
||||||
|
|
||||||
result := name
|
result := name
|
||||||
|
|||||||
+33
-398
@@ -238,7 +238,6 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
|||||||
return f.Save(filePath)
|
return f.Save(filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReadMetadata reads metadata from a FLAC file
|
|
||||||
func ReadMetadata(filePath string) (*Metadata, error) {
|
func ReadMetadata(filePath string) (*Metadata, error) {
|
||||||
f, err := flac.ParseFile(filePath)
|
f, err := flac.ParseFile(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -336,6 +335,39 @@ func fileExists(path string) bool {
|
|||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ExtractCoverArt(filePath string) ([]byte, error) {
|
||||||
|
f, err := flac.ParseFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse FLAC file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, meta := range f.Meta {
|
||||||
|
if meta.Type == flac.Picture {
|
||||||
|
pic, err := flacpicture.ParseFromMetaDataBlock(*meta)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if pic.PictureType == flacpicture.PictureTypeFrontCover && len(pic.ImageData) > 0 {
|
||||||
|
return pic.ImageData, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, meta := range f.Meta {
|
||||||
|
if meta.Type == flac.Picture {
|
||||||
|
pic, err := flacpicture.ParseFromMetaDataBlock(*meta)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(pic.ImageData) > 0 {
|
||||||
|
return pic.ImageData, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("no cover art found in file")
|
||||||
|
}
|
||||||
|
|
||||||
func EmbedLyrics(filePath string, lyrics string) error {
|
func EmbedLyrics(filePath string, lyrics string) error {
|
||||||
f, err := flac.ParseFile(filePath)
|
f, err := flac.ParseFile(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -418,7 +450,6 @@ func EmbedGenreLabel(filePath string, genre, label string) error {
|
|||||||
return f.Save(filePath)
|
return f.Save(filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExtractLyrics extracts embedded lyrics from a FLAC file
|
|
||||||
func ExtractLyrics(filePath string) (string, error) {
|
func ExtractLyrics(filePath string) (string, error) {
|
||||||
f, err := flac.ParseFile(filePath)
|
f, err := flac.ParseFile(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -512,356 +543,6 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
|
|||||||
return AudioQuality{}, fmt.Errorf("unsupported file format (not FLAC or M4A)")
|
return AudioQuality{}, fmt.Errorf("unsupported file format (not FLAC or M4A)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// M4A (MP4/AAC) Metadata Embedding
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// EmbedM4AMetadata embeds metadata into an M4A file using iTunes-style atoms
|
|
||||||
func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) error {
|
|
||||||
input, err := os.Open(filePath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to open M4A file: %w", err)
|
|
||||||
}
|
|
||||||
defer input.Close()
|
|
||||||
|
|
||||||
info, err := input.Stat()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to stat M4A file: %w", err)
|
|
||||||
}
|
|
||||||
fileSize := info.Size()
|
|
||||||
|
|
||||||
moovHeader, moovFound, err := findAtomInRange(input, 0, fileSize, "moov", fileSize)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to find moov atom: %w", err)
|
|
||||||
}
|
|
||||||
if !moovFound {
|
|
||||||
return fmt.Errorf("moov atom not found in M4A file")
|
|
||||||
}
|
|
||||||
|
|
||||||
moovContentStart := moovHeader.offset + moovHeader.headerSize
|
|
||||||
moovContentSize := moovHeader.size - moovHeader.headerSize
|
|
||||||
|
|
||||||
udtaHeader, udtaFound, err := findAtomInRange(input, moovContentStart, moovContentSize, "udta", fileSize)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to locate udta atom: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var metaHeader atomHeader
|
|
||||||
metaFound := false
|
|
||||||
if udtaFound {
|
|
||||||
udtaContentStart := udtaHeader.offset + udtaHeader.headerSize
|
|
||||||
udtaContentSize := udtaHeader.size - udtaHeader.headerSize
|
|
||||||
metaHeader, metaFound, err = findAtomInRange(input, udtaContentStart, udtaContentSize, "meta", fileSize)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to locate meta atom: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
metaAtom := buildMetaAtom(metadata, coverData)
|
|
||||||
metaSize := int64(len(metaAtom))
|
|
||||||
|
|
||||||
var delta int64
|
|
||||||
var newUdtaSize int64
|
|
||||||
switch {
|
|
||||||
case udtaFound && metaFound:
|
|
||||||
delta = metaSize - metaHeader.size
|
|
||||||
newUdtaSize = udtaHeader.size + delta
|
|
||||||
case udtaFound && !metaFound:
|
|
||||||
delta = metaSize
|
|
||||||
newUdtaSize = udtaHeader.size + delta
|
|
||||||
case !udtaFound:
|
|
||||||
newUdtaSize = int64(8 + len(metaAtom))
|
|
||||||
delta = newUdtaSize
|
|
||||||
}
|
|
||||||
|
|
||||||
newMoovSize := moovHeader.size + delta
|
|
||||||
if moovHeader.headerSize == 8 && newMoovSize > int64(^uint32(0)) {
|
|
||||||
return fmt.Errorf("moov atom exceeds 32-bit size after update")
|
|
||||||
}
|
|
||||||
if udtaFound && udtaHeader.headerSize == 8 && newUdtaSize > int64(^uint32(0)) {
|
|
||||||
return fmt.Errorf("udta atom exceeds 32-bit size after update")
|
|
||||||
}
|
|
||||||
if !udtaFound && newUdtaSize > int64(^uint32(0)) {
|
|
||||||
return fmt.Errorf("udta atom exceeds 32-bit size after update")
|
|
||||||
}
|
|
||||||
|
|
||||||
tempPath := filePath + ".tmp"
|
|
||||||
output, err := os.OpenFile(tempPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create temp file: %w", err)
|
|
||||||
}
|
|
||||||
cleanupTemp := true
|
|
||||||
defer func() {
|
|
||||||
_ = output.Close()
|
|
||||||
if cleanupTemp {
|
|
||||||
_ = os.Remove(tempPath)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case udtaFound && metaFound:
|
|
||||||
if err := copyRange(output, input, 0, moovHeader.offset); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := writeAtomHeader(output, "moov", newMoovSize, moovHeader.headerSize); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := copyRange(output, input, moovHeader.offset+moovHeader.headerSize, udtaHeader.offset-(moovHeader.offset+moovHeader.headerSize)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := writeAtomHeader(output, "udta", newUdtaSize, udtaHeader.headerSize); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := copyRange(output, input, udtaHeader.offset+udtaHeader.headerSize, metaHeader.offset-(udtaHeader.offset+udtaHeader.headerSize)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := output.Write(metaAtom); err != nil {
|
|
||||||
return fmt.Errorf("failed to write meta atom: %w", err)
|
|
||||||
}
|
|
||||||
metaEnd := metaHeader.offset + metaHeader.size
|
|
||||||
if err := copyRange(output, input, metaEnd, fileSize-metaEnd); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
case udtaFound && !metaFound:
|
|
||||||
if err := copyRange(output, input, 0, moovHeader.offset); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := writeAtomHeader(output, "moov", newMoovSize, moovHeader.headerSize); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := copyRange(output, input, moovHeader.offset+moovHeader.headerSize, udtaHeader.offset-(moovHeader.offset+moovHeader.headerSize)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := writeAtomHeader(output, "udta", newUdtaSize, udtaHeader.headerSize); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
insertPos := udtaHeader.offset + udtaHeader.size
|
|
||||||
if err := copyRange(output, input, udtaHeader.offset+udtaHeader.headerSize, insertPos-(udtaHeader.offset+udtaHeader.headerSize)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := output.Write(metaAtom); err != nil {
|
|
||||||
return fmt.Errorf("failed to write meta atom: %w", err)
|
|
||||||
}
|
|
||||||
if err := copyRange(output, input, insertPos, fileSize-insertPos); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
case !udtaFound:
|
|
||||||
newUdtaAtom := buildUdtaAtom(metaAtom)
|
|
||||||
if err := copyRange(output, input, 0, moovHeader.offset); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := writeAtomHeader(output, "moov", newMoovSize, moovHeader.headerSize); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
moovEnd := moovHeader.offset + moovHeader.size
|
|
||||||
if err := copyRange(output, input, moovHeader.offset+moovHeader.headerSize, moovEnd-(moovHeader.offset+moovHeader.headerSize)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := output.Write(newUdtaAtom); err != nil {
|
|
||||||
return fmt.Errorf("failed to write udta atom: %w", err)
|
|
||||||
}
|
|
||||||
if err := copyRange(output, input, moovEnd, fileSize-moovEnd); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := output.Close(); err != nil {
|
|
||||||
return fmt.Errorf("failed to close temp file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = input.Close()
|
|
||||||
if err := os.Remove(filePath); err != nil {
|
|
||||||
return fmt.Errorf("failed to replace original file: %w", err)
|
|
||||||
}
|
|
||||||
if err := os.Rename(tempPath, filePath); err != nil {
|
|
||||||
return fmt.Errorf("failed to move temp file: %w", err)
|
|
||||||
}
|
|
||||||
cleanupTemp = false
|
|
||||||
|
|
||||||
fmt.Printf("[M4A] Metadata embedded successfully\n")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildMetaAtom builds a complete meta atom with ilst containing metadata
|
|
||||||
func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
|
|
||||||
var ilst []byte
|
|
||||||
|
|
||||||
if metadata.Title != "" {
|
|
||||||
ilst = append(ilst, buildTextAtom("©nam", metadata.Title)...)
|
|
||||||
}
|
|
||||||
|
|
||||||
if metadata.Artist != "" {
|
|
||||||
ilst = append(ilst, buildTextAtom("©ART", metadata.Artist)...)
|
|
||||||
}
|
|
||||||
|
|
||||||
if metadata.Album != "" {
|
|
||||||
ilst = append(ilst, buildTextAtom("©alb", metadata.Album)...)
|
|
||||||
}
|
|
||||||
|
|
||||||
if metadata.AlbumArtist != "" {
|
|
||||||
ilst = append(ilst, buildTextAtom("aART", metadata.AlbumArtist)...)
|
|
||||||
}
|
|
||||||
|
|
||||||
if metadata.Date != "" {
|
|
||||||
ilst = append(ilst, buildTextAtom("©day", metadata.Date)...)
|
|
||||||
}
|
|
||||||
|
|
||||||
if metadata.TrackNumber > 0 {
|
|
||||||
ilst = append(ilst, buildTrackNumberAtom(metadata.TrackNumber, metadata.TotalTracks)...)
|
|
||||||
}
|
|
||||||
|
|
||||||
if metadata.DiscNumber > 0 {
|
|
||||||
ilst = append(ilst, buildDiscNumberAtom(metadata.DiscNumber, 0)...)
|
|
||||||
}
|
|
||||||
|
|
||||||
if metadata.Lyrics != "" {
|
|
||||||
ilst = append(ilst, buildTextAtom("©lyr", metadata.Lyrics)...)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(coverData) > 0 {
|
|
||||||
ilst = append(ilst, buildCoverAtom(coverData)...)
|
|
||||||
}
|
|
||||||
|
|
||||||
ilstSize := 8 + len(ilst)
|
|
||||||
ilstAtom := make([]byte, 4)
|
|
||||||
ilstAtom[0] = byte(ilstSize >> 24)
|
|
||||||
ilstAtom[1] = byte(ilstSize >> 16)
|
|
||||||
ilstAtom[2] = byte(ilstSize >> 8)
|
|
||||||
ilstAtom[3] = byte(ilstSize)
|
|
||||||
ilstAtom = append(ilstAtom, []byte("ilst")...)
|
|
||||||
ilstAtom = append(ilstAtom, ilst...)
|
|
||||||
|
|
||||||
hdlr := []byte{
|
|
||||||
0, 0, 0, 33, // size = 33
|
|
||||||
'h', 'd', 'l', 'r',
|
|
||||||
0, 0, 0, 0, // version + flags
|
|
||||||
0, 0, 0, 0, // predefined
|
|
||||||
'm', 'd', 'i', 'r', // handler type
|
|
||||||
'a', 'p', 'p', 'l', // manufacturer
|
|
||||||
0, 0, 0, 0, // component flags
|
|
||||||
0, 0, 0, 0, // component flags mask
|
|
||||||
0, // null terminator
|
|
||||||
}
|
|
||||||
|
|
||||||
metaContent := append([]byte{0, 0, 0, 0}, hdlr...) // version + flags + hdlr
|
|
||||||
metaContent = append(metaContent, ilstAtom...)
|
|
||||||
|
|
||||||
metaSize := 8 + len(metaContent)
|
|
||||||
metaAtom := make([]byte, 4)
|
|
||||||
metaAtom[0] = byte(metaSize >> 24)
|
|
||||||
metaAtom[1] = byte(metaSize >> 16)
|
|
||||||
metaAtom[2] = byte(metaSize >> 8)
|
|
||||||
metaAtom[3] = byte(metaSize)
|
|
||||||
metaAtom = append(metaAtom, []byte("meta")...)
|
|
||||||
metaAtom = append(metaAtom, metaContent...)
|
|
||||||
|
|
||||||
return metaAtom
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildTextAtom(name, value string) []byte {
|
|
||||||
valueBytes := []byte(value)
|
|
||||||
|
|
||||||
dataSize := 16 + len(valueBytes)
|
|
||||||
dataAtom := make([]byte, 4)
|
|
||||||
dataAtom[0] = byte(dataSize >> 24)
|
|
||||||
dataAtom[1] = byte(dataSize >> 16)
|
|
||||||
dataAtom[2] = byte(dataSize >> 8)
|
|
||||||
dataAtom[3] = byte(dataSize)
|
|
||||||
dataAtom = append(dataAtom, []byte("data")...)
|
|
||||||
dataAtom = append(dataAtom, 0, 0, 0, 1) // type = UTF-8
|
|
||||||
dataAtom = append(dataAtom, 0, 0, 0, 0) // locale
|
|
||||||
dataAtom = append(dataAtom, valueBytes...)
|
|
||||||
|
|
||||||
atomSize := 8 + len(dataAtom)
|
|
||||||
atom := make([]byte, 4)
|
|
||||||
atom[0] = byte(atomSize >> 24)
|
|
||||||
atom[1] = byte(atomSize >> 16)
|
|
||||||
atom[2] = byte(atomSize >> 8)
|
|
||||||
atom[3] = byte(atomSize)
|
|
||||||
atom = append(atom, []byte(name)...)
|
|
||||||
atom = append(atom, dataAtom...)
|
|
||||||
|
|
||||||
return atom
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildTrackNumberAtom builds trkn atom
|
|
||||||
func buildTrackNumberAtom(track, total int) []byte {
|
|
||||||
dataAtom := []byte{
|
|
||||||
0, 0, 0, 24, // size
|
|
||||||
'd', 'a', 't', 'a',
|
|
||||||
0, 0, 0, 0, // type = implicit
|
|
||||||
0, 0, 0, 0, // locale
|
|
||||||
0, 0, // padding
|
|
||||||
byte(track >> 8), byte(track), // track number
|
|
||||||
byte(total >> 8), byte(total), // total tracks
|
|
||||||
0, 0, // padding
|
|
||||||
}
|
|
||||||
|
|
||||||
atomSize := 8 + len(dataAtom)
|
|
||||||
atom := make([]byte, 4)
|
|
||||||
atom[0] = byte(atomSize >> 24)
|
|
||||||
atom[1] = byte(atomSize >> 16)
|
|
||||||
atom[2] = byte(atomSize >> 8)
|
|
||||||
atom[3] = byte(atomSize)
|
|
||||||
atom = append(atom, []byte("trkn")...)
|
|
||||||
atom = append(atom, dataAtom...)
|
|
||||||
|
|
||||||
return atom
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildDiscNumberAtom(disc, total int) []byte {
|
|
||||||
dataAtom := []byte{
|
|
||||||
0, 0, 0, 22, // size
|
|
||||||
'd', 'a', 't', 'a',
|
|
||||||
0, 0, 0, 0, // type = implicit
|
|
||||||
0, 0, 0, 0, // locale
|
|
||||||
0, 0, // padding
|
|
||||||
byte(disc >> 8), byte(disc), // disc number
|
|
||||||
byte(total >> 8), byte(total), // total discs
|
|
||||||
}
|
|
||||||
|
|
||||||
atomSize := 8 + len(dataAtom)
|
|
||||||
atom := make([]byte, 4)
|
|
||||||
atom[0] = byte(atomSize >> 24)
|
|
||||||
atom[1] = byte(atomSize >> 16)
|
|
||||||
atom[2] = byte(atomSize >> 8)
|
|
||||||
atom[3] = byte(atomSize)
|
|
||||||
atom = append(atom, []byte("disk")...)
|
|
||||||
atom = append(atom, dataAtom...)
|
|
||||||
|
|
||||||
return atom
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildCoverAtom builds covr atom with image data
|
|
||||||
func buildCoverAtom(coverData []byte) []byte {
|
|
||||||
imageType := byte(13)
|
|
||||||
if len(coverData) > 8 && coverData[0] == 0x89 && coverData[1] == 'P' && coverData[2] == 'N' && coverData[3] == 'G' {
|
|
||||||
imageType = 14
|
|
||||||
}
|
|
||||||
|
|
||||||
dataSize := 16 + len(coverData)
|
|
||||||
dataAtom := make([]byte, 4)
|
|
||||||
dataAtom[0] = byte(dataSize >> 24)
|
|
||||||
dataAtom[1] = byte(dataSize >> 16)
|
|
||||||
dataAtom[2] = byte(dataSize >> 8)
|
|
||||||
dataAtom[3] = byte(dataSize)
|
|
||||||
dataAtom = append(dataAtom, []byte("data")...)
|
|
||||||
dataAtom = append(dataAtom, 0, 0, 0, imageType)
|
|
||||||
dataAtom = append(dataAtom, 0, 0, 0, 0)
|
|
||||||
dataAtom = append(dataAtom, coverData...)
|
|
||||||
|
|
||||||
atomSize := 8 + len(dataAtom)
|
|
||||||
atom := make([]byte, 4)
|
|
||||||
atom[0] = byte(atomSize >> 24)
|
|
||||||
atom[1] = byte(atomSize >> 16)
|
|
||||||
atom[2] = byte(atomSize >> 8)
|
|
||||||
atom[3] = byte(atomSize)
|
|
||||||
atom = append(atom, []byte("covr")...)
|
|
||||||
atom = append(atom, dataAtom...)
|
|
||||||
|
|
||||||
return atom
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetM4AQuality(filePath string) (AudioQuality, error) {
|
func GetM4AQuality(filePath string) (AudioQuality, error) {
|
||||||
f, err := os.Open(filePath)
|
f, err := os.Open(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -974,52 +655,6 @@ func findAtomInRange(f *os.File, start, size int64, target string, fileSize int6
|
|||||||
return atomHeader{}, false, nil
|
return atomHeader{}, false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeAtomHeader(w io.Writer, typ string, size int64, headerSize int64) error {
|
|
||||||
if len(typ) != 4 {
|
|
||||||
return fmt.Errorf("invalid atom type: %s", typ)
|
|
||||||
}
|
|
||||||
|
|
||||||
if headerSize == 16 {
|
|
||||||
header := make([]byte, 16)
|
|
||||||
binary.BigEndian.PutUint32(header[0:4], 1)
|
|
||||||
copy(header[4:8], []byte(typ))
|
|
||||||
binary.BigEndian.PutUint64(header[8:16], uint64(size))
|
|
||||||
_, err := w.Write(header)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if size > int64(^uint32(0)) {
|
|
||||||
return fmt.Errorf("atom size exceeds 32-bit for %s", typ)
|
|
||||||
}
|
|
||||||
|
|
||||||
header := make([]byte, 8)
|
|
||||||
binary.BigEndian.PutUint32(header[0:4], uint32(size))
|
|
||||||
copy(header[4:8], []byte(typ))
|
|
||||||
_, err := w.Write(header)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func copyRange(dst io.Writer, src *os.File, offset, length int64) error {
|
|
||||||
if length <= 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if _, err := src.Seek(offset, io.SeekStart); err != nil {
|
|
||||||
return fmt.Errorf("failed to seek source: %w", err)
|
|
||||||
}
|
|
||||||
if _, err := io.CopyN(dst, src, length); err != nil {
|
|
||||||
return fmt.Errorf("failed to copy data: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildUdtaAtom(metaAtom []byte) []byte {
|
|
||||||
size := 8 + len(metaAtom)
|
|
||||||
header := make([]byte, 8)
|
|
||||||
binary.BigEndian.PutUint32(header[0:4], uint32(size))
|
|
||||||
copy(header[4:8], []byte("udta"))
|
|
||||||
return append(header, metaAtom...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func findAudioSampleEntry(f *os.File, start, end, fileSize int64) (int64, string, error) {
|
func findAudioSampleEntry(f *os.File, start, end, fileSize int64) (int64, string, error) {
|
||||||
const chunkSize = 64 * 1024
|
const chunkSize = 64 * 1024
|
||||||
patternMP4A := []byte("mp4a")
|
patternMP4A := []byte("mp4a")
|
||||||
|
|||||||
+5
-21
@@ -14,10 +14,9 @@ type TrackIDCacheEntry struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type TrackIDCache struct {
|
type TrackIDCache struct {
|
||||||
cache map[string]*TrackIDCacheEntry
|
cache map[string]*TrackIDCacheEntry
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
ttl time.Duration
|
ttl time.Duration
|
||||||
// Cleanup is triggered on writes at a fixed interval to avoid unbounded growth.
|
|
||||||
lastCleanup time.Time
|
lastCleanup time.Time
|
||||||
cleanupInterval time.Duration
|
cleanupInterval time.Duration
|
||||||
}
|
}
|
||||||
@@ -52,7 +51,6 @@ func (c *TrackIDCache) Get(isrc string) *TrackIDCacheEntry {
|
|||||||
return entry
|
return entry
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lazily delete expired entry.
|
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
entry, exists = c.cache[isrc]
|
entry, exists = c.cache[isrc]
|
||||||
if exists && time.Now().After(entry.ExpiresAt) {
|
if exists && time.Now().After(entry.ExpiresAt) {
|
||||||
@@ -139,7 +137,6 @@ func (c *TrackIDCache) Size() int {
|
|||||||
return len(c.cache)
|
return len(c.cache)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParallelDownloadResult holds results from parallel operations
|
|
||||||
type ParallelDownloadResult struct {
|
type ParallelDownloadResult struct {
|
||||||
CoverData []byte
|
CoverData []byte
|
||||||
LyricsData *LyricsResponse
|
LyricsData *LyricsResponse
|
||||||
@@ -164,14 +161,11 @@ func FetchCoverAndLyricsParallel(
|
|||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
fmt.Println("[Parallel] Starting cover download...")
|
|
||||||
data, err := downloadCoverToMemory(coverURL, maxQualityCover)
|
data, err := downloadCoverToMemory(coverURL, maxQualityCover)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result.CoverErr = err
|
result.CoverErr = err
|
||||||
fmt.Printf("[Parallel] Cover download failed: %v\n", err)
|
|
||||||
} else {
|
} else {
|
||||||
result.CoverData = data
|
result.CoverData = data
|
||||||
fmt.Printf("[Parallel] Cover downloaded: %d bytes\n", len(data))
|
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
@@ -180,20 +174,16 @@ func FetchCoverAndLyricsParallel(
|
|||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
fmt.Println("[Parallel] Starting lyrics fetch...")
|
|
||||||
client := NewLyricsClient()
|
client := NewLyricsClient()
|
||||||
durationSec := float64(durationMs) / 1000.0
|
durationSec := float64(durationMs) / 1000.0
|
||||||
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
|
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result.LyricsErr = err
|
result.LyricsErr = err
|
||||||
fmt.Printf("[Parallel] Lyrics fetch failed: %v\n", err)
|
|
||||||
} else if lyrics != nil && len(lyrics.Lines) > 0 {
|
} else if lyrics != nil && len(lyrics.Lines) > 0 {
|
||||||
result.LyricsData = lyrics
|
result.LyricsData = lyrics
|
||||||
result.LyricsLRC = convertToLRCWithMetadata(lyrics, trackName, artistName)
|
result.LyricsLRC = convertToLRCWithMetadata(lyrics, trackName, artistName)
|
||||||
fmt.Printf("[Parallel] Lyrics fetched: %d lines\n", len(lyrics.Lines))
|
|
||||||
} else {
|
} else {
|
||||||
result.LyricsErr = fmt.Errorf("no lyrics found")
|
result.LyricsErr = fmt.Errorf("no lyrics found")
|
||||||
fmt.Println("[Parallel] No lyrics found")
|
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
@@ -206,8 +196,8 @@ type PreWarmCacheRequest struct {
|
|||||||
ISRC string
|
ISRC string
|
||||||
TrackName string
|
TrackName string
|
||||||
ArtistName string
|
ArtistName string
|
||||||
SpotifyID string // Needed for Amazon (SongLink lookup)
|
SpotifyID string
|
||||||
Service string // "tidal", "qobuz", "amazon"
|
Service string
|
||||||
}
|
}
|
||||||
|
|
||||||
func PreWarmTrackCache(requests []PreWarmCacheRequest) {
|
func PreWarmTrackCache(requests []PreWarmCacheRequest) {
|
||||||
@@ -215,7 +205,6 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("[Cache] Pre-warming cache for %d tracks...\n", len(requests))
|
|
||||||
cache := GetTrackIDCache()
|
cache := GetTrackIDCache()
|
||||||
|
|
||||||
semaphore := make(chan struct{}, 3)
|
semaphore := make(chan struct{}, 3)
|
||||||
@@ -244,7 +233,6 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
fmt.Printf("[Cache] Pre-warm complete. Cache size: %d\n", cache.Size())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func preWarmTidalCache(isrc, _, _ string) {
|
func preWarmTidalCache(isrc, _, _ string) {
|
||||||
@@ -252,7 +240,6 @@ func preWarmTidalCache(isrc, _, _ string) {
|
|||||||
track, err := downloader.SearchTrackByISRC(isrc)
|
track, err := downloader.SearchTrackByISRC(isrc)
|
||||||
if err == nil && track != nil {
|
if err == nil && track != nil {
|
||||||
GetTrackIDCache().SetTidal(isrc, track.ID)
|
GetTrackIDCache().SetTidal(isrc, track.ID)
|
||||||
fmt.Printf("[Cache] Cached Tidal ID for ISRC %s: %d\n", isrc, track.ID)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,7 +248,6 @@ func preWarmQobuzCache(isrc string) {
|
|||||||
track, err := downloader.SearchTrackByISRC(isrc)
|
track, err := downloader.SearchTrackByISRC(isrc)
|
||||||
if err == nil && track != nil {
|
if err == nil && track != nil {
|
||||||
GetTrackIDCache().SetQobuz(isrc, track.ID)
|
GetTrackIDCache().SetQobuz(isrc, track.ID)
|
||||||
fmt.Printf("[Cache] Cached Qobuz ID for ISRC %s: %d\n", isrc, track.ID)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,7 +256,6 @@ func preWarmAmazonCache(isrc, spotifyID string) {
|
|||||||
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
|
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
|
||||||
if err == nil && availability != nil && availability.Amazon {
|
if err == nil && availability != nil && availability.Amazon {
|
||||||
GetTrackIDCache().SetAmazon(isrc, availability.AmazonURL)
|
GetTrackIDCache().SetAmazon(isrc, availability.AmazonURL)
|
||||||
fmt.Printf("[Cache] Cached Amazon URL for ISRC %s\n", isrc)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,7 +268,6 @@ func PreWarmCache(tracksJSON string) error {
|
|||||||
|
|
||||||
func ClearTrackCache() {
|
func ClearTrackCache() {
|
||||||
GetTrackIDCache().Clear()
|
GetTrackIDCache().Clear()
|
||||||
fmt.Println("[Cache] Track ID cache cleared")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetCacheSize() int {
|
func GetCacheSize() int {
|
||||||
|
|||||||
@@ -78,7 +78,6 @@ func GetItemProgress(itemID string) string {
|
|||||||
return "{}"
|
return "{}"
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartItemProgress initializes progress tracking for an item
|
|
||||||
func StartItemProgress(itemID string) {
|
func StartItemProgress(itemID string) {
|
||||||
multiMu.Lock()
|
multiMu.Lock()
|
||||||
defer multiMu.Unlock()
|
defer multiMu.Unlock()
|
||||||
@@ -93,7 +92,6 @@ func StartItemProgress(itemID string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetItemBytesTotal sets total bytes for an item
|
|
||||||
func SetItemBytesTotal(itemID string, total int64) {
|
func SetItemBytesTotal(itemID string, total int64) {
|
||||||
multiMu.Lock()
|
multiMu.Lock()
|
||||||
defer multiMu.Unlock()
|
defer multiMu.Unlock()
|
||||||
@@ -103,7 +101,6 @@ func SetItemBytesTotal(itemID string, total int64) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetItemBytesReceived sets bytes received for an item
|
|
||||||
func SetItemBytesReceived(itemID string, received int64) {
|
func SetItemBytesReceived(itemID string, received int64) {
|
||||||
multiMu.Lock()
|
multiMu.Lock()
|
||||||
defer multiMu.Unlock()
|
defer multiMu.Unlock()
|
||||||
@@ -116,7 +113,6 @@ func SetItemBytesReceived(itemID string, received int64) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetItemBytesReceivedWithSpeed sets bytes received and speed for an item
|
|
||||||
func SetItemBytesReceivedWithSpeed(itemID string, received int64, speedMBps float64) {
|
func SetItemBytesReceivedWithSpeed(itemID string, received int64, speedMBps float64) {
|
||||||
multiMu.Lock()
|
multiMu.Lock()
|
||||||
defer multiMu.Unlock()
|
defer multiMu.Unlock()
|
||||||
@@ -130,7 +126,6 @@ func SetItemBytesReceivedWithSpeed(itemID string, received int64, speedMBps floa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CompleteItemProgress marks an item as complete
|
|
||||||
func CompleteItemProgress(itemID string) {
|
func CompleteItemProgress(itemID string) {
|
||||||
multiMu.Lock()
|
multiMu.Lock()
|
||||||
defer multiMu.Unlock()
|
defer multiMu.Unlock()
|
||||||
@@ -142,7 +137,6 @@ func CompleteItemProgress(itemID string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetItemProgress sets progress for an item directly
|
|
||||||
func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal int64) {
|
func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal int64) {
|
||||||
multiMu.Lock()
|
multiMu.Lock()
|
||||||
defer multiMu.Unlock()
|
defer multiMu.Unlock()
|
||||||
@@ -158,7 +152,6 @@ func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetItemFinalizing marks an item as finalizing (embedding metadata)
|
|
||||||
func SetItemFinalizing(itemID string) {
|
func SetItemFinalizing(itemID string) {
|
||||||
multiMu.Lock()
|
multiMu.Lock()
|
||||||
defer multiMu.Unlock()
|
defer multiMu.Unlock()
|
||||||
@@ -169,7 +162,6 @@ func SetItemFinalizing(itemID string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveItemProgress removes progress tracking for an item
|
|
||||||
func RemoveItemProgress(itemID string) {
|
func RemoveItemProgress(itemID string) {
|
||||||
multiMu.Lock()
|
multiMu.Lock()
|
||||||
defer multiMu.Unlock()
|
defer multiMu.Unlock()
|
||||||
@@ -177,7 +169,6 @@ func RemoveItemProgress(itemID string) {
|
|||||||
delete(multiProgress.Items, itemID)
|
delete(multiProgress.Items, itemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearAllItemProgress clears all item progress
|
|
||||||
func ClearAllItemProgress() {
|
func ClearAllItemProgress() {
|
||||||
multiMu.Lock()
|
multiMu.Lock()
|
||||||
defer multiMu.Unlock()
|
defer multiMu.Unlock()
|
||||||
@@ -185,7 +176,6 @@ func ClearAllItemProgress() {
|
|||||||
multiProgress.Items = make(map[string]*ItemProgress)
|
multiProgress.Items = make(map[string]*ItemProgress)
|
||||||
}
|
}
|
||||||
|
|
||||||
// setDownloadDir sets the default download directory
|
|
||||||
func setDownloadDir(path string) error {
|
func setDownloadDir(path string) error {
|
||||||
downloadDirMu.Lock()
|
downloadDirMu.Lock()
|
||||||
defer downloadDirMu.Unlock()
|
defer downloadDirMu.Unlock()
|
||||||
@@ -193,7 +183,6 @@ func setDownloadDir(path string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ItemProgressWriter wraps io.Writer to track download progress for a specific item
|
|
||||||
type ItemProgressWriter struct {
|
type ItemProgressWriter struct {
|
||||||
writer interface{ Write([]byte) (int, error) }
|
writer interface{ Write([]byte) (int, error) }
|
||||||
itemID string
|
itemID string
|
||||||
@@ -206,7 +195,6 @@ type ItemProgressWriter struct {
|
|||||||
|
|
||||||
const progressUpdateThreshold = 64 * 1024 // Update progress every 64KB
|
const progressUpdateThreshold = 64 * 1024 // Update progress every 64KB
|
||||||
|
|
||||||
// NewItemProgressWriter creates a new progress writer for a specific item
|
|
||||||
func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID string) *ItemProgressWriter {
|
func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID string) *ItemProgressWriter {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
return &ItemProgressWriter{
|
return &ItemProgressWriter{
|
||||||
@@ -220,7 +208,6 @@ func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write implements io.Writer with threshold-based progress updates and speed tracking
|
|
||||||
func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
|
func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
|
||||||
if pw.itemID != "" && isDownloadCancelled(pw.itemID) {
|
if pw.itemID != "" && isDownloadCancelled(pw.itemID) {
|
||||||
return 0, ErrDownloadCancelled
|
return 0, ErrDownloadCancelled
|
||||||
|
|||||||
+27
-119
@@ -52,12 +52,10 @@ func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
|
|||||||
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
|
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
|
||||||
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
|
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
|
||||||
|
|
||||||
// Exact match
|
|
||||||
if normExpected == normFound {
|
if normExpected == normFound {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if one contains the other
|
|
||||||
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
|
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -112,24 +110,19 @@ func qobuzSplitArtists(artists string) []string {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// qobuzSameWordsUnordered checks if two strings have the same words regardless of order
|
|
||||||
// Useful for Japanese names: "Sawano Hiroyuki" vs "Hiroyuki Sawano"
|
|
||||||
func qobuzSameWordsUnordered(a, b string) bool {
|
func qobuzSameWordsUnordered(a, b string) bool {
|
||||||
wordsA := strings.Fields(a)
|
wordsA := strings.Fields(a)
|
||||||
wordsB := strings.Fields(b)
|
wordsB := strings.Fields(b)
|
||||||
|
|
||||||
// Must have same number of words
|
|
||||||
if len(wordsA) != len(wordsB) || len(wordsA) == 0 {
|
if len(wordsA) != len(wordsB) || len(wordsA) == 0 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort and compare
|
|
||||||
sortedA := make([]string, len(wordsA))
|
sortedA := make([]string, len(wordsA))
|
||||||
sortedB := make([]string, len(wordsB))
|
sortedB := make([]string, len(wordsB))
|
||||||
copy(sortedA, wordsA)
|
copy(sortedA, wordsA)
|
||||||
copy(sortedB, wordsB)
|
copy(sortedB, wordsB)
|
||||||
|
|
||||||
// Simple bubble sort (usually just 2-3 words)
|
|
||||||
for i := 0; i < len(sortedA)-1; i++ {
|
for i := 0; i < len(sortedA)-1; i++ {
|
||||||
for j := i + 1; j < len(sortedA); j++ {
|
for j := i + 1; j < len(sortedA); j++ {
|
||||||
if sortedA[i] > sortedA[j] {
|
if sortedA[i] > sortedA[j] {
|
||||||
@@ -153,7 +146,6 @@ func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
|
|||||||
normExpected := strings.ToLower(strings.TrimSpace(expectedTitle))
|
normExpected := strings.ToLower(strings.TrimSpace(expectedTitle))
|
||||||
normFound := strings.ToLower(strings.TrimSpace(foundTitle))
|
normFound := strings.ToLower(strings.TrimSpace(foundTitle))
|
||||||
|
|
||||||
// Exact match
|
|
||||||
if normExpected == normFound {
|
if normExpected == normFound {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -182,8 +174,6 @@ func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// If scripts are TRULY different (Latin vs CJK/Arabic/Cyrillic), assume match (transliteration)
|
|
||||||
// Don't treat Latin Extended (Polish, French, etc.) as different script
|
|
||||||
expectedLatin := qobuzIsLatinScript(expectedTitle)
|
expectedLatin := qobuzIsLatinScript(expectedTitle)
|
||||||
foundLatin := qobuzIsLatinScript(foundTitle)
|
foundLatin := qobuzIsLatinScript(foundTitle)
|
||||||
if expectedLatin != foundLatin {
|
if expectedLatin != foundLatin {
|
||||||
@@ -194,9 +184,7 @@ func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// qobuzExtractCoreTitle extracts the main title before any parentheses or brackets
|
|
||||||
func qobuzExtractCoreTitle(title string) string {
|
func qobuzExtractCoreTitle(title string) string {
|
||||||
// Find first occurrence of ( or [
|
|
||||||
parenIdx := strings.Index(title, "(")
|
parenIdx := strings.Index(title, "(")
|
||||||
bracketIdx := strings.Index(title, "[")
|
bracketIdx := strings.Index(title, "[")
|
||||||
dashIdx := strings.Index(title, " - ")
|
dashIdx := strings.Index(title, " - ")
|
||||||
@@ -281,49 +269,28 @@ func qobuzCleanTitle(title string) string {
|
|||||||
return strings.TrimSpace(cleaned)
|
return strings.TrimSpace(cleaned)
|
||||||
}
|
}
|
||||||
|
|
||||||
// qobuzIsLatinScript checks if a string is primarily Latin script
|
|
||||||
// Returns true for ASCII and Latin Extended characters (European languages)
|
|
||||||
// Returns false for CJK, Arabic, Cyrillic, etc.
|
|
||||||
func qobuzIsLatinScript(s string) bool {
|
func qobuzIsLatinScript(s string) bool {
|
||||||
for _, r := range s {
|
for _, r := range s {
|
||||||
// Skip common punctuation and numbers
|
|
||||||
if r < 128 {
|
if r < 128 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Latin Extended-A: U+0100 to U+017F (Polish, Czech, etc.)
|
if (r >= 0x0100 && r <= 0x024F) ||
|
||||||
// Latin Extended-B: U+0180 to U+024F
|
(r >= 0x1E00 && r <= 0x1EFF) ||
|
||||||
// Latin Extended Additional: U+1E00 to U+1EFF
|
(r >= 0x00C0 && r <= 0x00FF) {
|
||||||
// Latin Extended-C/D/E: various ranges
|
|
||||||
if (r >= 0x0100 && r <= 0x024F) || // Latin Extended A & B
|
|
||||||
(r >= 0x1E00 && r <= 0x1EFF) || // Latin Extended Additional
|
|
||||||
(r >= 0x00C0 && r <= 0x00FF) { // Latin-1 Supplement (accented chars)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// CJK ranges - definitely different script
|
if (r >= 0x4E00 && r <= 0x9FFF) ||
|
||||||
if (r >= 0x4E00 && r <= 0x9FFF) || // CJK Unified Ideographs
|
(r >= 0x3040 && r <= 0x309F) ||
|
||||||
(r >= 0x3040 && r <= 0x309F) || // Hiragana
|
(r >= 0x30A0 && r <= 0x30FF) ||
|
||||||
(r >= 0x30A0 && r <= 0x30FF) || // Katakana
|
(r >= 0xAC00 && r <= 0xD7AF) ||
|
||||||
(r >= 0xAC00 && r <= 0xD7AF) || // Hangul (Korean)
|
(r >= 0x0600 && r <= 0x06FF) ||
|
||||||
(r >= 0x0600 && r <= 0x06FF) || // Arabic
|
(r >= 0x0400 && r <= 0x04FF) {
|
||||||
(r >= 0x0400 && r <= 0x04FF) { // Cyrillic
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// qobuzIsASCIIString checks if a string contains only ASCII characters
|
|
||||||
// Kept for potential future use
|
|
||||||
// func qobuzIsASCIIString(s string) bool {
|
|
||||||
// for _, r := range s {
|
|
||||||
// if r > 127 {
|
|
||||||
// return false
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// return true
|
|
||||||
// }
|
|
||||||
|
|
||||||
// containsQueryQobuz checks if a query already exists in the list
|
|
||||||
func containsQueryQobuz(queries []string, query string) bool {
|
func containsQueryQobuz(queries []string, query string) bool {
|
||||||
for _, q := range queries {
|
for _, q := range queries {
|
||||||
if q == query {
|
if q == query {
|
||||||
@@ -336,7 +303,7 @@ func containsQueryQobuz(queries []string, query string) bool {
|
|||||||
func NewQobuzDownloader() *QobuzDownloader {
|
func NewQobuzDownloader() *QobuzDownloader {
|
||||||
qobuzDownloaderOnce.Do(func() {
|
qobuzDownloaderOnce.Do(func() {
|
||||||
globalQobuzDownloader = &QobuzDownloader{
|
globalQobuzDownloader = &QobuzDownloader{
|
||||||
client: NewHTTPClientWithTimeout(DefaultTimeout), // 60s timeout
|
client: NewHTTPClientWithTimeout(DefaultTimeout),
|
||||||
appID: "798273057",
|
appID: "798273057",
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -344,7 +311,6 @@ func NewQobuzDownloader() *QobuzDownloader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) {
|
func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) {
|
||||||
// Qobuz API: /track/get?track_id=XXX
|
|
||||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9nZXQ/dHJhY2tfaWQ9")
|
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9nZXQ/dHJhY2tfaWQ9")
|
||||||
trackURL := fmt.Sprintf("%s%d&app_id=%s", string(apiBase), trackID, q.appID)
|
trackURL := fmt.Sprintf("%s%d&app_id=%s", string(apiBase), trackID, q.appID)
|
||||||
|
|
||||||
@@ -371,15 +337,11 @@ func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) {
|
|||||||
return &track, nil
|
return &track, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAvailableAPIs returns list of available Qobuz APIs
|
|
||||||
// Uses same APIs as PC version for compatibility
|
|
||||||
func (q *QobuzDownloader) GetAvailableAPIs() []string {
|
func (q *QobuzDownloader) GetAvailableAPIs() []string {
|
||||||
// Same APIs as PC version (referensi/backend/qobuz.go)
|
|
||||||
// Primary: dab.yeet.su, Fallback: dabmusic.xyz, qobuz.squid.wtf
|
|
||||||
encodedAPIs := []string{
|
encodedAPIs := []string{
|
||||||
"ZGFiLnllZXQuc3UvYXBpL3N0cmVhbT90cmFja0lkPQ==", // dab.yeet.su/api/stream?trackId=
|
"ZGFiLnllZXQuc3UvYXBpL3N0cmVhbT90cmFja0lkPQ==",
|
||||||
"ZGFibXVzaWMueHl6L2FwaS9zdHJlYW0/dHJhY2tJZD0=", // dabmusic.xyz/api/stream?trackId=
|
"ZGFibXVzaWMueHl6L2FwaS9zdHJlYW0/dHJhY2tJZD0=",
|
||||||
"cW9idXouc3F1aWQud3RmL2FwaS9kb3dubG9hZC1tdXNpYz90cmFja19pZD0=", // qobuz.squid.wtf/api/download-music?track_id=
|
"cW9idXouc3F1aWQud3RmL2FwaS9kb3dubG9hZC1tdXNpYz90cmFja19pZD0=",
|
||||||
}
|
}
|
||||||
|
|
||||||
var apis []string
|
var apis []string
|
||||||
@@ -394,21 +356,19 @@ func (q *QobuzDownloader) GetAvailableAPIs() []string {
|
|||||||
return apis
|
return apis
|
||||||
}
|
}
|
||||||
|
|
||||||
// mapJumoQuality maps Qobuz quality codes to Jumo format
|
|
||||||
func mapJumoQuality(quality string) int {
|
func mapJumoQuality(quality string) int {
|
||||||
switch quality {
|
switch quality {
|
||||||
case "6":
|
case "6":
|
||||||
return 6 // 16-bit FLAC
|
return 6
|
||||||
case "7":
|
case "7":
|
||||||
return 7 // 24-bit 96kHz
|
return 7
|
||||||
case "27":
|
case "27":
|
||||||
return 27 // 24-bit 192kHz
|
return 27
|
||||||
default:
|
default:
|
||||||
return 6
|
return 6
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// decodeXOR decodes XOR-encoded response from Jumo API
|
|
||||||
func decodeXOR(data []byte) string {
|
func decodeXOR(data []byte) string {
|
||||||
text := string(data)
|
text := string(data)
|
||||||
runes := []rune(text)
|
runes := []rune(text)
|
||||||
@@ -420,12 +380,9 @@ func decodeXOR(data []byte) string {
|
|||||||
return string(result)
|
return string(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// downloadFromJumo gets download URL from Jumo API (fallback)
|
|
||||||
func (q *QobuzDownloader) downloadFromJumo(trackID int64, quality string) (string, error) {
|
func (q *QobuzDownloader) downloadFromJumo(trackID int64, quality string) (string, error) {
|
||||||
formatID := mapJumoQuality(quality)
|
formatID := mapJumoQuality(quality)
|
||||||
region := "US"
|
region := "US"
|
||||||
|
|
||||||
// Jumo API endpoint
|
|
||||||
jumoURL := fmt.Sprintf("https://jumo-dl.pages.dev/file?track_id=%d&format_id=%d®ion=%s", trackID, formatID, region)
|
jumoURL := fmt.Sprintf("https://jumo-dl.pages.dev/file?track_id=%d&format_id=%d®ion=%s", trackID, formatID, region)
|
||||||
|
|
||||||
GoLog("[Qobuz] Trying Jumo API fallback...\n")
|
GoLog("[Qobuz] Trying Jumo API fallback...\n")
|
||||||
@@ -452,17 +409,13 @@ func (q *QobuzDownloader) downloadFromJumo(trackID int64, quality string) (strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
var result map[string]any
|
var result map[string]any
|
||||||
|
|
||||||
// Try parsing as plain JSON first
|
|
||||||
if err := json.Unmarshal(body, &result); err != nil {
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
// Try XOR decoding
|
|
||||||
decoded := decodeXOR(body)
|
decoded := decodeXOR(body)
|
||||||
if err := json.Unmarshal([]byte(decoded), &result); err != nil {
|
if err := json.Unmarshal([]byte(decoded), &result); err != nil {
|
||||||
return "", fmt.Errorf("failed to parse Jumo response (plain or XOR): %w", err)
|
return "", fmt.Errorf("failed to parse Jumo response (plain or XOR): %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for URL in various response formats
|
|
||||||
if urlVal, ok := result["url"].(string); ok && urlVal != "" {
|
if urlVal, ok := result["url"].(string); ok && urlVal != "" {
|
||||||
GoLog("[Qobuz] Jumo API returned URL successfully\n")
|
GoLog("[Qobuz] Jumo API returned URL successfully\n")
|
||||||
return urlVal, nil
|
return urlVal, nil
|
||||||
@@ -511,7 +464,6 @@ func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find exact ISRC match
|
|
||||||
for i := range result.Tracks.Items {
|
for i := range result.Tracks.Items {
|
||||||
if result.Tracks.Items[i].ISRC == isrc {
|
if result.Tracks.Items[i].ISRC == isrc {
|
||||||
return &result.Tracks.Items[i], nil
|
return &result.Tracks.Items[i], nil
|
||||||
@@ -525,7 +477,6 @@ func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) {
|
|||||||
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
|
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
|
||||||
}
|
}
|
||||||
|
|
||||||
// expectedDurationSec is the expected duration in seconds (0 to skip verification)
|
|
||||||
func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDurationSec int) (*QobuzTrack, error) {
|
func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDurationSec int) (*QobuzTrack, error) {
|
||||||
GoLog("[Qobuz] Searching by ISRC: %s\n", isrc)
|
GoLog("[Qobuz] Searching by ISRC: %s\n", isrc)
|
||||||
|
|
||||||
@@ -558,7 +509,6 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
|
|||||||
|
|
||||||
GoLog("[Qobuz] ISRC search returned %d results\n", len(result.Tracks.Items))
|
GoLog("[Qobuz] ISRC search returned %d results\n", len(result.Tracks.Items))
|
||||||
|
|
||||||
// Find ISRC matches
|
|
||||||
var isrcMatches []*QobuzTrack
|
var isrcMatches []*QobuzTrack
|
||||||
for i := range result.Tracks.Items {
|
for i := range result.Tracks.Items {
|
||||||
if result.Tracks.Items[i].ISRC == isrc {
|
if result.Tracks.Items[i].ISRC == isrc {
|
||||||
@@ -612,35 +562,26 @@ func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*
|
|||||||
return q.SearchTrackByMetadataWithDuration(trackName, artistName, 0)
|
return q.SearchTrackByMetadataWithDuration(trackName, artistName, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now includes romaji conversion for Japanese text (same as Tidal)
|
|
||||||
// Also includes title verification to prevent wrong song downloads
|
|
||||||
func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) {
|
func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) {
|
||||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
||||||
|
|
||||||
// Try multiple search strategies (same as Tidal/PC version)
|
|
||||||
queries := []string{}
|
queries := []string{}
|
||||||
|
|
||||||
// Strategy 1: Artist + Track name
|
|
||||||
if artistName != "" && trackName != "" {
|
if artistName != "" && trackName != "" {
|
||||||
queries = append(queries, artistName+" "+trackName)
|
queries = append(queries, artistName+" "+trackName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 2: Track name only
|
|
||||||
if trackName != "" {
|
if trackName != "" {
|
||||||
queries = append(queries, trackName)
|
queries = append(queries, trackName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 3: Romaji versions if Japanese detected
|
|
||||||
if ContainsJapanese(trackName) || ContainsJapanese(artistName) {
|
if ContainsJapanese(trackName) || ContainsJapanese(artistName) {
|
||||||
// Convert to romaji (hiragana/katakana only, kanji stays)
|
|
||||||
romajiTrack := JapaneseToRomaji(trackName)
|
romajiTrack := JapaneseToRomaji(trackName)
|
||||||
romajiArtist := JapaneseToRomaji(artistName)
|
romajiArtist := JapaneseToRomaji(artistName)
|
||||||
|
|
||||||
// Clean and remove ALL non-ASCII characters (including kanji)
|
|
||||||
cleanRomajiTrack := CleanToASCII(romajiTrack)
|
cleanRomajiTrack := CleanToASCII(romajiTrack)
|
||||||
cleanRomajiArtist := CleanToASCII(romajiArtist)
|
cleanRomajiArtist := CleanToASCII(romajiArtist)
|
||||||
|
|
||||||
// Artist + Track romaji (cleaned to ASCII only)
|
|
||||||
if cleanRomajiArtist != "" && cleanRomajiTrack != "" {
|
if cleanRomajiArtist != "" && cleanRomajiTrack != "" {
|
||||||
romajiQuery := cleanRomajiArtist + " " + cleanRomajiTrack
|
romajiQuery := cleanRomajiArtist + " " + cleanRomajiTrack
|
||||||
if !containsQueryQobuz(queries, romajiQuery) {
|
if !containsQueryQobuz(queries, romajiQuery) {
|
||||||
@@ -649,7 +590,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track romaji only (cleaned)
|
|
||||||
if cleanRomajiTrack != "" && cleanRomajiTrack != trackName {
|
if cleanRomajiTrack != "" && cleanRomajiTrack != trackName {
|
||||||
if !containsQueryQobuz(queries, cleanRomajiTrack) {
|
if !containsQueryQobuz(queries, cleanRomajiTrack) {
|
||||||
queries = append(queries, cleanRomajiTrack)
|
queries = append(queries, cleanRomajiTrack)
|
||||||
@@ -657,7 +597,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 4: Artist only as last resort
|
|
||||||
if artistName != "" {
|
if artistName != "" {
|
||||||
artistOnly := CleanToASCII(JapaneseToRomaji(artistName))
|
artistOnly := CleanToASCII(JapaneseToRomaji(artistName))
|
||||||
if artistOnly != "" && !containsQueryQobuz(queries, artistOnly) {
|
if artistOnly != "" && !containsQueryQobuz(queries, artistOnly) {
|
||||||
@@ -716,7 +655,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
|||||||
return nil, fmt.Errorf("no tracks found for: %s - %s", artistName, trackName)
|
return nil, fmt.Errorf("no tracks found for: %s - %s", artistName, trackName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by title match first (NEW - like Tidal)
|
|
||||||
var titleMatches []*QobuzTrack
|
var titleMatches []*QobuzTrack
|
||||||
for i := range allTracks {
|
for i := range allTracks {
|
||||||
track := &allTracks[i]
|
track := &allTracks[i]
|
||||||
@@ -727,7 +665,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
|||||||
|
|
||||||
GoLog("[Qobuz] Title matches: %d out of %d results\n", len(titleMatches), len(allTracks))
|
GoLog("[Qobuz] Title matches: %d out of %d results\n", len(titleMatches), len(allTracks))
|
||||||
|
|
||||||
// If no title matches, log warning but continue with all tracks
|
|
||||||
tracksToCheck := titleMatches
|
tracksToCheck := titleMatches
|
||||||
if len(titleMatches) == 0 {
|
if len(titleMatches) == 0 {
|
||||||
GoLog("[Qobuz] WARNING: No title matches for '%s', checking all %d results\n", trackName, len(allTracks))
|
GoLog("[Qobuz] WARNING: No title matches for '%s', checking all %d results\n", trackName, len(allTracks))
|
||||||
@@ -736,7 +673,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If duration verification is requested
|
|
||||||
if expectedDurationSec > 0 {
|
if expectedDurationSec > 0 {
|
||||||
var durationMatches []*QobuzTrack
|
var durationMatches []*QobuzTrack
|
||||||
for _, track := range tracksToCheck {
|
for _, track := range tracksToCheck {
|
||||||
@@ -765,7 +701,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
|||||||
return nil, fmt.Errorf("no tracks found with matching title and duration (expected '%s', %ds)", trackName, expectedDurationSec)
|
return nil, fmt.Errorf("no tracks found with matching title and duration (expected '%s', %ds)", trackName, expectedDurationSec)
|
||||||
}
|
}
|
||||||
|
|
||||||
// No duration verification, return best quality from title matches
|
|
||||||
for _, track := range tracksToCheck {
|
for _, track := range tracksToCheck {
|
||||||
if track.MaximumBitDepth >= 24 {
|
if track.MaximumBitDepth >= 24 {
|
||||||
GoLog("[Qobuz] Match found: '%s' by '%s' (title verified, hi-res)\n",
|
GoLog("[Qobuz] Match found: '%s' by '%s' (title verified, hi-res)\n",
|
||||||
@@ -783,7 +718,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
|||||||
return nil, fmt.Errorf("no matching track found for: %s - %s", artistName, trackName)
|
return nil, fmt.Errorf("no matching track found for: %s - %s", artistName, trackName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// qobuzAPIResult holds the result from a parallel API request
|
|
||||||
type qobuzAPIResult struct {
|
type qobuzAPIResult struct {
|
||||||
apiURL string
|
apiURL string
|
||||||
downloadURL string
|
downloadURL string
|
||||||
@@ -801,7 +735,6 @@ func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (
|
|||||||
resultChan := make(chan qobuzAPIResult, len(apis))
|
resultChan := make(chan qobuzAPIResult, len(apis))
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
|
|
||||||
// Start all requests in parallel
|
|
||||||
for _, apiURL := range apis {
|
for _, apiURL := range apis {
|
||||||
go func(api string) {
|
go func(api string) {
|
||||||
reqStart := time.Now()
|
reqStart := time.Now()
|
||||||
@@ -834,13 +767,11 @@ func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if response is HTML (error page)
|
|
||||||
if len(body) > 0 && body[0] == '<' {
|
if len(body) > 0 && body[0] == '<' {
|
||||||
resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("received HTML instead of JSON"), duration: time.Since(reqStart)}
|
resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("received HTML instead of JSON"), duration: time.Since(reqStart)}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for error in JSON response
|
|
||||||
var errorResp struct {
|
var errorResp struct {
|
||||||
Error string `json:"error"`
|
Error string `json:"error"`
|
||||||
}
|
}
|
||||||
@@ -866,7 +797,6 @@ func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (
|
|||||||
}(apiURL)
|
}(apiURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect results - return first success
|
|
||||||
var errors []string
|
var errors []string
|
||||||
|
|
||||||
for i := 0; i < len(apis); i++ {
|
for i := 0; i < len(apis); i++ {
|
||||||
@@ -874,7 +804,6 @@ func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (
|
|||||||
if result.err == nil {
|
if result.err == nil {
|
||||||
GoLog("[Qobuz] [Parallel] Got response from %s in %v\n", result.apiURL, result.duration)
|
GoLog("[Qobuz] [Parallel] Got response from %s in %v\n", result.apiURL, result.duration)
|
||||||
|
|
||||||
// Drain remaining results to avoid goroutine leaks
|
|
||||||
go func(remaining int) {
|
go func(remaining int) {
|
||||||
for j := 0; j < remaining; j++ {
|
for j := 0; j < remaining; j++ {
|
||||||
<-resultChan
|
<-resultChan
|
||||||
@@ -906,14 +835,12 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
|
|||||||
return downloadURL, nil
|
return downloadURL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// All standard APIs failed, try Jumo as fallback
|
|
||||||
GoLog("[Qobuz] Standard APIs failed, trying Jumo fallback...\n")
|
GoLog("[Qobuz] Standard APIs failed, trying Jumo fallback...\n")
|
||||||
jumoURL, jumoErr := q.downloadFromJumo(trackID, quality)
|
jumoURL, jumoErr := q.downloadFromJumo(trackID, quality)
|
||||||
if jumoErr == nil {
|
if jumoErr == nil {
|
||||||
return jumoURL, nil
|
return jumoURL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// If quality is 27 (hi-res), try fallback to lower quality
|
|
||||||
if quality == "27" {
|
if quality == "27" {
|
||||||
GoLog("[Qobuz] Hi-res (27) failed, trying 24-bit (7)...\n")
|
GoLog("[Qobuz] Hi-res (27) failed, trying 24-bit (7)...\n")
|
||||||
jumoURL, jumoErr = q.downloadFromJumo(trackID, "7")
|
jumoURL, jumoErr = q.downloadFromJumo(trackID, "7")
|
||||||
@@ -933,11 +860,9 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
|
|||||||
return "", fmt.Errorf("all Qobuz APIs and Jumo fallback failed: %w", err)
|
return "", fmt.Errorf("all Qobuz APIs and Jumo fallback failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DownloadFile downloads a file from URL with User-Agent and progress tracking
|
|
||||||
func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Initialize item progress (required for all downloads)
|
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
StartItemProgress(itemID)
|
StartItemProgress(itemID)
|
||||||
defer CompleteItemProgress(itemID)
|
defer CompleteItemProgress(itemID)
|
||||||
@@ -987,7 +912,6 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
|||||||
written, err = io.Copy(bufWriter, resp.Body)
|
written, err = io.Copy(bufWriter, resp.Body)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flush buffer before checking for errors
|
|
||||||
flushErr := bufWriter.Flush()
|
flushErr := bufWriter.Flush()
|
||||||
closeErr := out.Close()
|
closeErr := out.Close()
|
||||||
|
|
||||||
@@ -1007,7 +931,6 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
|||||||
return fmt.Errorf("failed to close file: %w", closeErr)
|
return fmt.Errorf("failed to close file: %w", closeErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify file size if Content-Length was provided
|
|
||||||
if expectedSize > 0 && written != expectedSize {
|
if expectedSize > 0 && written != expectedSize {
|
||||||
os.Remove(outputPath)
|
os.Remove(outputPath)
|
||||||
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
||||||
@@ -1055,11 +978,9 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// OPTIMIZATION: Check cache first for track ID
|
|
||||||
if track == nil && req.ISRC != "" {
|
if track == nil && req.ISRC != "" {
|
||||||
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.QobuzTrackID > 0 {
|
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.QobuzTrackID > 0 {
|
||||||
GoLog("[Qobuz] Cache hit! Using cached track ID: %d\n", cached.QobuzTrackID)
|
GoLog("[Qobuz] Cache hit! Using cached track ID: %d\n", cached.QobuzTrackID)
|
||||||
// For Qobuz we need to search again to get full track info, but we can use the ID
|
|
||||||
track, err = downloader.SearchTrackByISRC(req.ISRC)
|
track, err = downloader.SearchTrackByISRC(req.ISRC)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
GoLog("[Qobuz] Cache hit but search failed: %v\n", err)
|
GoLog("[Qobuz] Cache hit but search failed: %v\n", err)
|
||||||
@@ -1068,11 +989,9 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 1: Search by ISRC with duration verification
|
|
||||||
if track == nil && req.ISRC != "" {
|
if track == nil && req.ISRC != "" {
|
||||||
GoLog("[Qobuz] Trying ISRC search: %s\n", req.ISRC)
|
GoLog("[Qobuz] Trying ISRC search: %s\n", req.ISRC)
|
||||||
track, err = downloader.SearchTrackByISRCWithDuration(req.ISRC, expectedDurationSec)
|
track, err = downloader.SearchTrackByISRCWithDuration(req.ISRC, expectedDurationSec)
|
||||||
// Verify artist AND title
|
|
||||||
if track != nil {
|
if track != nil {
|
||||||
if !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
|
if !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
|
||||||
GoLog("[Qobuz] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
|
GoLog("[Qobuz] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
|
||||||
@@ -1086,10 +1005,8 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 2: Search by metadata with duration verification (includes title verification)
|
|
||||||
if track == nil {
|
if track == nil {
|
||||||
track, err = downloader.SearchTrackByMetadataWithDuration(req.TrackName, req.ArtistName, expectedDurationSec)
|
track, err = downloader.SearchTrackByMetadataWithDuration(req.TrackName, req.ArtistName, expectedDurationSec)
|
||||||
// Verify artist (title already verified in SearchTrackByMetadataWithDuration)
|
|
||||||
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
|
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
|
||||||
GoLog("[Qobuz] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
|
GoLog("[Qobuz] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
|
||||||
req.ArtistName, track.Performer.Name)
|
req.ArtistName, track.Performer.Name)
|
||||||
@@ -1105,7 +1022,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
return QobuzDownloadResult{}, fmt.Errorf("qobuz search failed: %s", errMsg)
|
return QobuzDownloadResult{}, fmt.Errorf("qobuz search failed: %s", errMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log match found and cache the track ID
|
|
||||||
GoLog("[Qobuz] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, track.Performer.Name, track.Duration)
|
GoLog("[Qobuz] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, track.Performer.Name, track.Duration)
|
||||||
if req.ISRC != "" {
|
if req.ISRC != "" {
|
||||||
GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
|
GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
|
||||||
@@ -1126,22 +1042,19 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
return QobuzDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
return QobuzDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map quality from Tidal format to Qobuz format
|
qobuzQuality := "27"
|
||||||
// Tidal: LOSSLESS (16-bit), HI_RES (24-bit), HI_RES_LOSSLESS (24-bit hi-res)
|
|
||||||
// Qobuz: 5 (MP3 320), 6 (16-bit), 7 (24-bit 96kHz), 27 (24-bit 192kHz)
|
|
||||||
qobuzQuality := "27" // Default to highest quality
|
|
||||||
switch req.Quality {
|
switch req.Quality {
|
||||||
case "LOSSLESS":
|
case "LOSSLESS":
|
||||||
qobuzQuality = "6" // 16-bit FLAC
|
qobuzQuality = "6"
|
||||||
case "HI_RES":
|
case "HI_RES":
|
||||||
qobuzQuality = "7" // 24-bit 96kHz
|
qobuzQuality = "7"
|
||||||
case "HI_RES_LOSSLESS":
|
case "HI_RES_LOSSLESS":
|
||||||
qobuzQuality = "27" // 24-bit 192kHz
|
qobuzQuality = "27"
|
||||||
}
|
}
|
||||||
GoLog("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality)
|
GoLog("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality)
|
||||||
|
|
||||||
actualBitDepth := track.MaximumBitDepth
|
actualBitDepth := track.MaximumBitDepth
|
||||||
actualSampleRate := int(track.MaximumSamplingRate * 1000) // Convert kHz to Hz
|
actualSampleRate := int(track.MaximumSamplingRate * 1000)
|
||||||
GoLog("[Qobuz] Actual quality: %d-bit/%.1fkHz\n", actualBitDepth, track.MaximumSamplingRate)
|
GoLog("[Qobuz] Actual quality: %d-bit/%.1fkHz\n", actualBitDepth, track.MaximumSamplingRate)
|
||||||
|
|
||||||
downloadURL, err := downloader.GetDownloadURL(track.ID, qobuzQuality)
|
downloadURL, err := downloader.GetDownloadURL(track.ID, qobuzQuality)
|
||||||
@@ -1149,7 +1062,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
return QobuzDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
return QobuzDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// START PARALLEL: Fetch cover and lyrics while downloading audio
|
|
||||||
var parallelResult *ParallelDownloadResult
|
var parallelResult *ParallelDownloadResult
|
||||||
parallelDone := make(chan struct{})
|
parallelDone := make(chan struct{})
|
||||||
go func() {
|
go func() {
|
||||||
@@ -1165,7 +1077,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
)
|
)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Download audio file with item ID for progress tracking
|
|
||||||
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
|
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
|
||||||
if errors.Is(err, ErrDownloadCancelled) {
|
if errors.Is(err, ErrDownloadCancelled) {
|
||||||
return QobuzDownloadResult{}, ErrDownloadCancelled
|
return QobuzDownloadResult{}, ErrDownloadCancelled
|
||||||
@@ -1173,7 +1084,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
return QobuzDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
return QobuzDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for parallel operations to complete
|
|
||||||
<-parallelDone
|
<-parallelDone
|
||||||
|
|
||||||
if req.ItemID != "" {
|
if req.ItemID != "" {
|
||||||
@@ -1186,7 +1096,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
albumName = req.AlbumName
|
albumName = req.AlbumName
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use track number from request if available, otherwise from Qobuz API
|
|
||||||
actualTrackNumber := req.TrackNumber
|
actualTrackNumber := req.TrackNumber
|
||||||
if actualTrackNumber == 0 {
|
if actualTrackNumber == 0 {
|
||||||
actualTrackNumber = track.TrackNumber
|
actualTrackNumber = track.TrackNumber
|
||||||
@@ -1196,15 +1105,15 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
Title: track.Title,
|
Title: track.Title,
|
||||||
Artist: track.Performer.Name,
|
Artist: track.Performer.Name,
|
||||||
Album: albumName,
|
Album: albumName,
|
||||||
AlbumArtist: req.AlbumArtist, // Qobuz track struct might not have this handy, keep req or check album struct
|
AlbumArtist: req.AlbumArtist,
|
||||||
Date: track.Album.ReleaseDate,
|
Date: track.Album.ReleaseDate,
|
||||||
TrackNumber: actualTrackNumber,
|
TrackNumber: actualTrackNumber,
|
||||||
TotalTracks: req.TotalTracks,
|
TotalTracks: req.TotalTracks,
|
||||||
DiscNumber: req.DiscNumber, // QobuzTrack struct usually doesn't have disc info in simple search result
|
DiscNumber: req.DiscNumber,
|
||||||
ISRC: track.ISRC,
|
ISRC: track.ISRC,
|
||||||
Genre: req.Genre, // From Deezer album metadata
|
Genre: req.Genre,
|
||||||
Label: req.Label, // From Deezer album metadata
|
Label: req.Label,
|
||||||
Copyright: req.Copyright, // From Deezer album metadata
|
Copyright: req.Copyright,
|
||||||
}
|
}
|
||||||
|
|
||||||
var coverData []byte
|
var coverData []byte
|
||||||
@@ -1244,7 +1153,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
fmt.Println("[Qobuz] No lyrics available from parallel fetch")
|
fmt.Println("[Qobuz] No lyrics available from parallel fetch")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add to ISRC index for fast duplicate checking
|
|
||||||
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
|
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
|
||||||
|
|
||||||
return QobuzDownloadResult{
|
return QobuzDownloadResult{
|
||||||
@@ -1256,7 +1164,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
Album: track.Album.Title,
|
Album: track.Album.Title,
|
||||||
ReleaseDate: track.Album.ReleaseDate,
|
ReleaseDate: track.Album.ReleaseDate,
|
||||||
TrackNumber: actualTrackNumber,
|
TrackNumber: actualTrackNumber,
|
||||||
DiscNumber: req.DiscNumber, // Qobuz track struct limitations
|
DiscNumber: req.DiscNumber,
|
||||||
ISRC: track.ISRC,
|
ISRC: track.ISRC,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,33 +43,6 @@ func NewSongLinkClient() *SongLinkClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
|
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
|
||||||
if spotifyTrackID == "" {
|
|
||||||
return nil, fmt.Errorf("spotify track ID is empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try SongLink first
|
|
||||||
availability, err := s.checkTrackAvailabilitySongLink(spotifyTrackID)
|
|
||||||
if err != nil {
|
|
||||||
// Fallback to IDHS if SongLink fails
|
|
||||||
LogWarn("SongLink", "SongLink failed, trying IDHS fallback: %v", err)
|
|
||||||
idhsClient := NewIDHSClient()
|
|
||||||
availability, err = idhsClient.GetAvailabilityFromSpotify(spotifyTrackID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("both SongLink and IDHS failed: %w", err)
|
|
||||||
}
|
|
||||||
LogInfo("SongLink", "IDHS fallback successful for %s", spotifyTrackID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check Qobuz availability separately via ISRC
|
|
||||||
if isrc != "" {
|
|
||||||
availability.Qobuz = checkQobuzAvailability(isrc)
|
|
||||||
}
|
|
||||||
|
|
||||||
return availability, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkTrackAvailabilitySongLink is the original SongLink implementation
|
|
||||||
func (s *SongLinkClient) checkTrackAvailabilitySongLink(spotifyTrackID string) (*TrackAvailability, error) {
|
|
||||||
songLinkRateLimiter.WaitForSlot()
|
songLinkRateLimiter.WaitForSlot()
|
||||||
|
|
||||||
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
|
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
|
||||||
@@ -227,10 +200,8 @@ type AlbumAvailability struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAvailability, error) {
|
func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAvailability, error) {
|
||||||
// Use global rate limiter
|
|
||||||
songLinkRateLimiter.WaitForSlot()
|
songLinkRateLimiter.WaitForSlot()
|
||||||
|
|
||||||
// Build API URL for album
|
|
||||||
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL2FsYnVtLw==")
|
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL2FsYnVtLw==")
|
||||||
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyAlbumID)
|
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyAlbumID)
|
||||||
|
|
||||||
@@ -301,10 +272,8 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra
|
|||||||
return nil, fmt.Errorf("deezer track ID is empty")
|
return nil, fmt.Errorf("deezer track ID is empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try SongLink first
|
|
||||||
availability, err := s.checkAvailabilityFromDeezerSongLink(deezerTrackID)
|
availability, err := s.checkAvailabilityFromDeezerSongLink(deezerTrackID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Fallback to IDHS if SongLink fails
|
|
||||||
LogWarn("SongLink", "SongLink failed for Deezer, trying IDHS fallback: %v", err)
|
LogWarn("SongLink", "SongLink failed for Deezer, trying IDHS fallback: %v", err)
|
||||||
idhsClient := NewIDHSClient()
|
idhsClient := NewIDHSClient()
|
||||||
availability, err = idhsClient.GetAvailabilityFromDeezer(deezerTrackID)
|
availability, err = idhsClient.GetAvailabilityFromDeezer(deezerTrackID)
|
||||||
@@ -338,7 +307,6 @@ func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID strin
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
// Handle specific error codes
|
|
||||||
if resp.StatusCode == 400 {
|
if resp.StatusCode == 400 {
|
||||||
return nil, fmt.Errorf("track not found on SongLink (invalid Deezer ID)")
|
return nil, fmt.Errorf("track not found on SongLink (invalid Deezer ID)")
|
||||||
}
|
}
|
||||||
@@ -407,11 +375,8 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
|
|||||||
return nil, fmt.Errorf("%s ID is empty", platform)
|
return nil, fmt.Errorf("%s ID is empty", platform)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use global rate limiter
|
|
||||||
songLinkRateLimiter.WaitForSlot()
|
songLinkRateLimiter.WaitForSlot()
|
||||||
|
|
||||||
// Build API URL using platform, type, and id parameters (as per API docs)
|
|
||||||
// https://api.song.link/v1-alpha.1/links?platform=deezer&type=song&id=123456
|
|
||||||
apiURL := fmt.Sprintf("https://api.song.link/v1-alpha.1/links?platform=%s&type=%s&id=%s&userCountry=US",
|
apiURL := fmt.Sprintf("https://api.song.link/v1-alpha.1/links?platform=%s&type=%s&id=%s&userCountry=US",
|
||||||
url.QueryEscape(platform),
|
url.QueryEscape(platform),
|
||||||
url.QueryEscape(entityType),
|
url.QueryEscape(entityType),
|
||||||
@@ -429,7 +394,6 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
// Handle specific error codes
|
|
||||||
if resp.StatusCode == 400 {
|
if resp.StatusCode == 400 {
|
||||||
return nil, fmt.Errorf("track not found on SongLink (invalid %s ID)", platform)
|
return nil, fmt.Errorf("track not found on SongLink (invalid %s ID)", platform)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ var (
|
|||||||
// ErrNoSpotifyCredentials is returned when Spotify credentials are not configured
|
// ErrNoSpotifyCredentials is returned when Spotify credentials are not configured
|
||||||
var ErrNoSpotifyCredentials = errors.New("Spotify credentials not configured. Please set your own Client ID and Secret in Settings, or use Deezer as metadata source (free, no credentials required)")
|
var ErrNoSpotifyCredentials = errors.New("Spotify credentials not configured. Please set your own Client ID and Secret in Settings, or use Deezer as metadata source (free, no credentials required)")
|
||||||
|
|
||||||
// SetSpotifyCredentials sets custom Spotify API credentials
|
|
||||||
func SetSpotifyCredentials(clientID, clientSecret string) {
|
func SetSpotifyCredentials(clientID, clientSecret string) {
|
||||||
credentialsMu.Lock()
|
credentialsMu.Lock()
|
||||||
defer credentialsMu.Unlock()
|
defer credentialsMu.Unlock()
|
||||||
@@ -89,7 +88,6 @@ func HasSpotifyCredentials() bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// getCredentials returns the current credentials or error if not configured
|
|
||||||
func getCredentials() (string, string, error) {
|
func getCredentials() (string, string, error) {
|
||||||
credentialsMu.RLock()
|
credentialsMu.RLock()
|
||||||
defer credentialsMu.RUnlock()
|
defer credentialsMu.RUnlock()
|
||||||
|
|||||||
+75
-26
@@ -119,19 +119,18 @@ func NewTidalDownloader() *TidalDownloader {
|
|||||||
return globalTidalDownloader
|
return globalTidalDownloader
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAvailableAPIs returns list of available Tidal APIs
|
|
||||||
func (t *TidalDownloader) GetAvailableAPIs() []string {
|
func (t *TidalDownloader) GetAvailableAPIs() []string {
|
||||||
encodedAPIs := []string{
|
encodedAPIs := []string{
|
||||||
|
"dGlkYWwtYXBpLmJpbmltdW0ub3Jn", // tidal-api.binimum.org (priority)
|
||||||
"dGlkYWwua2lub3BsdXMub25saW5l", // tidal.kinoplus.online
|
"dGlkYWwua2lub3BsdXMub25saW5l", // tidal.kinoplus.online
|
||||||
"dGlkYWwtYXBpLmJpbmltdW0ub3Jn", // tidal-api.binimum.org
|
|
||||||
"dHJpdG9uLnNxdWlkLnd0Zg==", // triton.squid.wtf
|
"dHJpdG9uLnNxdWlkLnd0Zg==", // triton.squid.wtf
|
||||||
"aGlmaS1vbmUuc3BvdGlzYXZlci5uZXQ=", // hifi-one.spotisaver.net
|
|
||||||
"aGlmaS10d28uc3BvdGlzYXZlci5uZXQ=", // hifi-two.spotisaver.net
|
|
||||||
"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
|
||||||
|
"aGlmaS1vbmUuc3BvdGlzYXZlci5uZXQ=", // hifi-one.spotisaver.net
|
||||||
|
"aGlmaS10d28uc3BvdGlzYXZlci5uZXQ=", // hifi-two.spotisaver.net
|
||||||
}
|
}
|
||||||
|
|
||||||
var apis []string
|
var apis []string
|
||||||
@@ -251,7 +250,6 @@ func (t *TidalDownloader) GetTrackIDFromURL(tidalURL string) (int64, error) {
|
|||||||
return trackID, nil
|
return trackID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTrackInfoByID gets track info by Tidal track ID
|
|
||||||
func (t *TidalDownloader) GetTrackInfoByID(trackID int64) (*TidalTrack, error) {
|
func (t *TidalDownloader) GetTrackInfoByID(trackID int64) (*TidalTrack, error) {
|
||||||
token, err := t.GetAccessToken()
|
token, err := t.GetAccessToken()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -797,7 +795,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
|
|
||||||
func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -970,7 +967,15 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
m4aPath := strings.TrimSuffix(outputPath, ".flac") + ".m4a"
|
// For DASH format, determine correct M4A path
|
||||||
|
// If outputPath already ends with .m4a, use it directly
|
||||||
|
// Otherwise, convert .flac to .m4a
|
||||||
|
var m4aPath string
|
||||||
|
if strings.HasSuffix(outputPath, ".m4a") {
|
||||||
|
m4aPath = outputPath
|
||||||
|
} else {
|
||||||
|
m4aPath = strings.TrimSuffix(outputPath, ".flac") + ".m4a"
|
||||||
|
}
|
||||||
GoLog("[Tidal] DASH format - downloading %d segments directly to: %s\n", len(mediaURLs), m4aPath)
|
GoLog("[Tidal] DASH format - downloading %d segments directly to: %s\n", len(mediaURLs), m4aPath)
|
||||||
|
|
||||||
out, err := os.Create(m4aPath)
|
out, err := os.Create(m4aPath)
|
||||||
@@ -1096,6 +1101,7 @@ type TidalDownloadResult struct {
|
|||||||
TrackNumber int
|
TrackNumber int
|
||||||
DiscNumber int
|
DiscNumber int
|
||||||
ISRC string
|
ISRC string
|
||||||
|
LyricsLRC string // LRC content for embedding in converted files
|
||||||
}
|
}
|
||||||
|
|
||||||
func artistsMatch(spotifyArtist, tidalArtist string) bool {
|
func artistsMatch(spotifyArtist, tidalArtist string) bool {
|
||||||
@@ -1106,7 +1112,6 @@ func artistsMatch(spotifyArtist, tidalArtist string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if one contains the other (for cases like "Artist" vs "Artist feat. Someone")
|
|
||||||
if strings.Contains(normSpotify, normTidal) || strings.Contains(normTidal, normSpotify) {
|
if strings.Contains(normSpotify, normTidal) || strings.Contains(normTidal, normSpotify) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -1165,7 +1170,6 @@ func sameWordsUnordered(a, b string) bool {
|
|||||||
wordsA := strings.Fields(a)
|
wordsA := strings.Fields(a)
|
||||||
wordsB := strings.Fields(b)
|
wordsB := strings.Fields(b)
|
||||||
|
|
||||||
// Must have same number of words
|
|
||||||
if len(wordsA) != len(wordsB) || len(wordsA) == 0 {
|
if len(wordsA) != len(wordsB) || len(wordsA) == 0 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -1198,7 +1202,6 @@ func titlesMatch(expectedTitle, foundTitle string) bool {
|
|||||||
normExpected := strings.ToLower(strings.TrimSpace(expectedTitle))
|
normExpected := strings.ToLower(strings.TrimSpace(expectedTitle))
|
||||||
normFound := strings.ToLower(strings.TrimSpace(foundTitle))
|
normFound := strings.ToLower(strings.TrimSpace(foundTitle))
|
||||||
|
|
||||||
// Exact match
|
|
||||||
if normExpected == normFound {
|
if normExpected == normFound {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -1207,7 +1210,6 @@ func titlesMatch(expectedTitle, foundTitle string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean both titles and compare
|
|
||||||
cleanExpected := cleanTitle(normExpected)
|
cleanExpected := cleanTitle(normExpected)
|
||||||
cleanFound := cleanTitle(normFound)
|
cleanFound := cleanTitle(normFound)
|
||||||
|
|
||||||
@@ -1221,7 +1223,6 @@ func titlesMatch(expectedTitle, foundTitle string) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract core title (before any parentheses/brackets)
|
|
||||||
coreExpected := extractCoreTitle(normExpected)
|
coreExpected := extractCoreTitle(normExpected)
|
||||||
coreFound := extractCoreTitle(normFound)
|
coreFound := extractCoreTitle(normFound)
|
||||||
|
|
||||||
@@ -1229,7 +1230,6 @@ func titlesMatch(expectedTitle, foundTitle string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't treat Latin Extended (Polish, French, etc.) as different script
|
|
||||||
expectedLatin := isLatinScript(expectedTitle)
|
expectedLatin := isLatinScript(expectedTitle)
|
||||||
foundLatin := isLatinScript(foundTitle)
|
foundLatin := isLatinScript(foundTitle)
|
||||||
if expectedLatin != foundLatin {
|
if expectedLatin != foundLatin {
|
||||||
@@ -1502,6 +1502,11 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
GetTrackIDCache().SetTidal(req.ISRC, track.ID)
|
GetTrackIDCache().SetTidal(req.ISRC, track.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
quality := req.Quality
|
||||||
|
if quality == "" {
|
||||||
|
quality = "LOSSLESS"
|
||||||
|
}
|
||||||
|
|
||||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
||||||
"title": req.TrackName,
|
"title": req.TrackName,
|
||||||
"artist": req.ArtistName,
|
"artist": req.ArtistName,
|
||||||
@@ -1510,15 +1515,26 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
"year": extractYear(req.ReleaseDate),
|
"year": extractYear(req.ReleaseDate),
|
||||||
"disc": req.DiscNumber,
|
"disc": req.DiscNumber,
|
||||||
})
|
})
|
||||||
filename = sanitizeFilename(filename) + ".flac"
|
|
||||||
outputPath := filepath.Join(req.OutputDir, filename)
|
var outputPath string
|
||||||
|
var m4aPath string
|
||||||
|
if quality == "HIGH" {
|
||||||
|
filename = sanitizeFilename(filename) + ".m4a"
|
||||||
|
outputPath = filepath.Join(req.OutputDir, filename)
|
||||||
|
m4aPath = outputPath
|
||||||
|
} else {
|
||||||
|
filename = sanitizeFilename(filename) + ".flac"
|
||||||
|
outputPath = filepath.Join(req.OutputDir, filename)
|
||||||
|
m4aPath = strings.TrimSuffix(outputPath, ".flac") + ".m4a"
|
||||||
|
}
|
||||||
|
|
||||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||||
return TidalDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
return TidalDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||||
}
|
}
|
||||||
m4aPath := strings.TrimSuffix(outputPath, ".flac") + ".m4a"
|
if quality != "HIGH" {
|
||||||
if fileInfo, statErr := os.Stat(m4aPath); statErr == nil && fileInfo.Size() > 0 {
|
if fileInfo, statErr := os.Stat(m4aPath); statErr == nil && fileInfo.Size() > 0 {
|
||||||
return TidalDownloadResult{FilePath: "EXISTS:" + m4aPath}, nil
|
return TidalDownloadResult{FilePath: "EXISTS:" + m4aPath}, nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tmpPath := outputPath + ".m4a.tmp"
|
tmpPath := outputPath + ".m4a.tmp"
|
||||||
@@ -1527,10 +1543,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
os.Remove(tmpPath)
|
os.Remove(tmpPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
quality := req.Quality
|
|
||||||
if quality == "" {
|
|
||||||
quality = "LOSSLESS"
|
|
||||||
}
|
|
||||||
GoLog("[Tidal] Using quality: %s\n", quality)
|
GoLog("[Tidal] Using quality: %s\n", quality)
|
||||||
|
|
||||||
downloadInfo, err := downloader.GetDownloadURL(track.ID, quality)
|
downloadInfo, err := downloader.GetDownloadURL(track.ID, quality)
|
||||||
@@ -1593,7 +1605,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
GoLog("[Tidal] Using release date from Tidal API: %s\n", releaseDate)
|
GoLog("[Tidal] Using release date from Tidal API: %s\n", releaseDate)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use track number from request if available, otherwise from Tidal API
|
|
||||||
actualTrackNumber := req.TrackNumber
|
actualTrackNumber := req.TrackNumber
|
||||||
actualDiscNumber := req.DiscNumber
|
actualDiscNumber := req.DiscNumber
|
||||||
if actualTrackNumber == 0 {
|
if actualTrackNumber == 0 {
|
||||||
@@ -1656,15 +1667,52 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
fmt.Println("[Tidal] No lyrics available from parallel fetch")
|
fmt.Println("[Tidal] No lyrics available from parallel fetch")
|
||||||
}
|
}
|
||||||
} else if strings.HasSuffix(actualOutputPath, ".m4a") {
|
} else if strings.HasSuffix(actualOutputPath, ".m4a") {
|
||||||
fmt.Println("[Tidal] Skipping metadata embedding for M4A file (will be handled after FFmpeg conversion)")
|
if quality == "HIGH" {
|
||||||
|
GoLog("[Tidal] HIGH quality M4A - skipping metadata embedding (file from server is already valid)\n")
|
||||||
|
|
||||||
|
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||||
|
lyricsMode := req.LyricsMode
|
||||||
|
if lyricsMode == "" {
|
||||||
|
lyricsMode = "embed"
|
||||||
|
}
|
||||||
|
|
||||||
|
if lyricsMode == "external" || lyricsMode == "both" {
|
||||||
|
GoLog("[Tidal] Saving external LRC file for M4A (mode: %s)...\n", lyricsMode)
|
||||||
|
if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
||||||
|
GoLog("[Tidal] Warning: failed to save LRC file: %v\n", lrcErr)
|
||||||
|
} else {
|
||||||
|
GoLog("[Tidal] LRC file saved: %s\n", lrcPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Println("[Tidal] Skipping metadata embedding for M4A file (will be handled after FFmpeg conversion)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
AddToISRCIndex(req.OutputDir, req.ISRC, actualOutputPath)
|
AddToISRCIndex(req.OutputDir, req.ISRC, actualOutputPath)
|
||||||
|
|
||||||
|
bitDepth := downloadInfo.BitDepth
|
||||||
|
sampleRate := downloadInfo.SampleRate
|
||||||
|
lyricsLRC := ""
|
||||||
|
if quality == "HIGH" {
|
||||||
|
bitDepth = 0
|
||||||
|
sampleRate = 44100
|
||||||
|
if parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||||
|
lyricsMode := req.LyricsMode
|
||||||
|
if lyricsMode == "" {
|
||||||
|
lyricsMode = "embed"
|
||||||
|
}
|
||||||
|
if lyricsMode == "embed" || lyricsMode == "both" {
|
||||||
|
lyricsLRC = parallelResult.LyricsLRC
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return TidalDownloadResult{
|
return TidalDownloadResult{
|
||||||
FilePath: actualOutputPath,
|
FilePath: actualOutputPath,
|
||||||
BitDepth: downloadInfo.BitDepth,
|
BitDepth: bitDepth,
|
||||||
SampleRate: downloadInfo.SampleRate,
|
SampleRate: sampleRate,
|
||||||
Title: track.Title,
|
Title: track.Title,
|
||||||
Artist: track.Artist.Name,
|
Artist: track.Artist.Name,
|
||||||
Album: track.Album.Title,
|
Album: track.Album.Title,
|
||||||
@@ -1672,5 +1720,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
TrackNumber: actualTrackNumber,
|
TrackNumber: actualTrackNumber,
|
||||||
DiscNumber: actualDiscNumber,
|
DiscNumber: actualDiscNumber,
|
||||||
ISRC: track.ISRC,
|
ISRC: track.ISRC,
|
||||||
|
LyricsLRC: lyricsLRC,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = '3.3.1';
|
static const String version = '3.3.5';
|
||||||
static const String buildNumber = '68';
|
static const String buildNumber = '70';
|
||||||
static const String fullVersion = '$version+$buildNumber';
|
static const String fullVersion = '$version+$buildNumber';
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1312,6 +1312,12 @@ abstract class AppLocalizations {
|
|||||||
/// **'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.'**
|
/// **'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.'**
|
||||||
String get setupIosEmptyFolderWarning;
|
String get setupIosEmptyFolderWarning;
|
||||||
|
|
||||||
|
/// Error when user selects iCloud Drive on iOS
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'iCloud Drive is not supported. Please use the app Documents folder.'**
|
||||||
|
String get setupIcloudNotSupported;
|
||||||
|
|
||||||
/// App tagline in setup
|
/// App tagline in setup
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -3646,6 +3652,42 @@ abstract class AppLocalizations {
|
|||||||
/// **'Are you sure you want to clear all downloads?'**
|
/// **'Are you sure you want to clear all downloads?'**
|
||||||
String get queueClearAllMessage;
|
String get queueClearAllMessage;
|
||||||
|
|
||||||
|
/// Button - export failed downloads to TXT
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Export'**
|
||||||
|
String get queueExportFailed;
|
||||||
|
|
||||||
|
/// Success message after exporting failed downloads
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Failed downloads exported to TXT file'**
|
||||||
|
String get queueExportFailedSuccess;
|
||||||
|
|
||||||
|
/// Action to clear failed downloads after export
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Clear Failed'**
|
||||||
|
String get queueExportFailedClear;
|
||||||
|
|
||||||
|
/// Error message when export fails
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Failed to export downloads'**
|
||||||
|
String get queueExportFailedError;
|
||||||
|
|
||||||
|
/// Setting toggle for auto-export
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Auto-export failed downloads'**
|
||||||
|
String get settingsAutoExportFailed;
|
||||||
|
|
||||||
|
/// Subtitle for auto-export setting
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Save failed downloads to TXT file automatically'**
|
||||||
|
String get settingsAutoExportFailedSubtitle;
|
||||||
|
|
||||||
/// Empty queue state title
|
/// Empty queue state title
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|||||||
@@ -698,6 +698,10 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get setupIosEmptyFolderWarning =>
|
String get setupIosEmptyFolderWarning =>
|
||||||
'iOS-Einschränkung: Leere Ordner können nicht ausgewählt werden. Wählen Sie einen Ordner mit mindestens einer Datei.';
|
'iOS-Einschränkung: Leere Ordner können nicht ausgewählt werden. Wählen Sie einen Ordner mit mindestens einer Datei.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupIcloudNotSupported =>
|
||||||
|
'iCloud Drive is not supported. Please use the app Documents folder.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupDownloadInFlac => 'Spotify Titel in FLAC herunterladen';
|
String get setupDownloadInFlac => 'Spotify Titel in FLAC herunterladen';
|
||||||
|
|
||||||
@@ -2001,6 +2005,26 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get queueClearAllMessage =>
|
String get queueClearAllMessage =>
|
||||||
'Are you sure you want to clear all downloads?';
|
'Are you sure you want to clear all downloads?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailed => 'Export';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailedSuccess =>
|
||||||
|
'Failed downloads exported to TXT file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailedClear => 'Clear Failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailedError => 'Failed to export downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsAutoExportFailed => 'Auto-export failed downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsAutoExportFailedSubtitle =>
|
||||||
|
'Save failed downloads to TXT file automatically';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get queueEmpty => 'No downloads in queue';
|
String get queueEmpty => 'No downloads in queue';
|
||||||
|
|
||||||
|
|||||||
@@ -684,6 +684,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get setupIosEmptyFolderWarning =>
|
String get setupIosEmptyFolderWarning =>
|
||||||
'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.';
|
'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupIcloudNotSupported =>
|
||||||
|
'iCloud Drive is not supported. Please use the app Documents folder.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupDownloadInFlac => 'Download Spotify tracks in FLAC';
|
String get setupDownloadInFlac => 'Download Spotify tracks in FLAC';
|
||||||
|
|
||||||
@@ -1986,6 +1990,26 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get queueClearAllMessage =>
|
String get queueClearAllMessage =>
|
||||||
'Are you sure you want to clear all downloads?';
|
'Are you sure you want to clear all downloads?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailed => 'Export';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailedSuccess =>
|
||||||
|
'Failed downloads exported to TXT file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailedClear => 'Clear Failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailedError => 'Failed to export downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsAutoExportFailed => 'Auto-export failed downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsAutoExportFailedSubtitle =>
|
||||||
|
'Save failed downloads to TXT file automatically';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get queueEmpty => 'No downloads in queue';
|
String get queueEmpty => 'No downloads in queue';
|
||||||
|
|
||||||
|
|||||||
@@ -684,6 +684,10 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
String get setupIosEmptyFolderWarning =>
|
String get setupIosEmptyFolderWarning =>
|
||||||
'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.';
|
'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupIcloudNotSupported =>
|
||||||
|
'iCloud Drive is not supported. Please use the app Documents folder.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupDownloadInFlac => 'Download Spotify tracks in FLAC';
|
String get setupDownloadInFlac => 'Download Spotify tracks in FLAC';
|
||||||
|
|
||||||
@@ -1986,6 +1990,26 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
String get queueClearAllMessage =>
|
String get queueClearAllMessage =>
|
||||||
'Are you sure you want to clear all downloads?';
|
'Are you sure you want to clear all downloads?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailed => 'Export';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailedSuccess =>
|
||||||
|
'Failed downloads exported to TXT file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailedClear => 'Clear Failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailedError => 'Failed to export downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsAutoExportFailed => 'Auto-export failed downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsAutoExportFailedSubtitle =>
|
||||||
|
'Save failed downloads to TXT file automatically';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get queueEmpty => 'No downloads in queue';
|
String get queueEmpty => 'No downloads in queue';
|
||||||
|
|
||||||
|
|||||||
@@ -684,6 +684,10 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get setupIosEmptyFolderWarning =>
|
String get setupIosEmptyFolderWarning =>
|
||||||
'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.';
|
'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupIcloudNotSupported =>
|
||||||
|
'iCloud Drive is not supported. Please use the app Documents folder.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupDownloadInFlac => 'Download Spotify tracks in FLAC';
|
String get setupDownloadInFlac => 'Download Spotify tracks in FLAC';
|
||||||
|
|
||||||
@@ -1986,6 +1990,26 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get queueClearAllMessage =>
|
String get queueClearAllMessage =>
|
||||||
'Are you sure you want to clear all downloads?';
|
'Are you sure you want to clear all downloads?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailed => 'Export';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailedSuccess =>
|
||||||
|
'Failed downloads exported to TXT file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailedClear => 'Clear Failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailedError => 'Failed to export downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsAutoExportFailed => 'Auto-export failed downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsAutoExportFailedSubtitle =>
|
||||||
|
'Save failed downloads to TXT file automatically';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get queueEmpty => 'No downloads in queue';
|
String get queueEmpty => 'No downloads in queue';
|
||||||
|
|
||||||
|
|||||||
@@ -684,6 +684,10 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
String get setupIosEmptyFolderWarning =>
|
String get setupIosEmptyFolderWarning =>
|
||||||
'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.';
|
'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupIcloudNotSupported =>
|
||||||
|
'iCloud Drive is not supported. Please use the app Documents folder.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupDownloadInFlac => 'Download Spotify tracks in FLAC';
|
String get setupDownloadInFlac => 'Download Spotify tracks in FLAC';
|
||||||
|
|
||||||
@@ -1986,6 +1990,26 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
String get queueClearAllMessage =>
|
String get queueClearAllMessage =>
|
||||||
'Are you sure you want to clear all downloads?';
|
'Are you sure you want to clear all downloads?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailed => 'Export';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailedSuccess =>
|
||||||
|
'Failed downloads exported to TXT file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailedClear => 'Clear Failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailedError => 'Failed to export downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsAutoExportFailed => 'Auto-export failed downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsAutoExportFailedSubtitle =>
|
||||||
|
'Save failed downloads to TXT file automatically';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get queueEmpty => 'No downloads in queue';
|
String get queueEmpty => 'No downloads in queue';
|
||||||
|
|
||||||
|
|||||||
@@ -689,6 +689,10 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get setupIosEmptyFolderWarning =>
|
String get setupIosEmptyFolderWarning =>
|
||||||
'Batasan iOS: Folder kosong tidak dapat dipilih. Pilih folder dengan minimal satu file.';
|
'Batasan iOS: Folder kosong tidak dapat dipilih. Pilih folder dengan minimal satu file.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupIcloudNotSupported =>
|
||||||
|
'iCloud Drive is not supported. Please use the app Documents folder.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupDownloadInFlac => 'Unduh lagu Spotify dalam format FLAC';
|
String get setupDownloadInFlac => 'Unduh lagu Spotify dalam format FLAC';
|
||||||
|
|
||||||
@@ -1999,6 +2003,26 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get queueClearAllMessage =>
|
String get queueClearAllMessage =>
|
||||||
'Apakah Anda yakin ingin menghapus semua unduhan?';
|
'Apakah Anda yakin ingin menghapus semua unduhan?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailed => 'Export';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailedSuccess =>
|
||||||
|
'Failed downloads exported to TXT file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailedClear => 'Clear Failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailedError => 'Failed to export downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsAutoExportFailed => 'Auto-export failed downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsAutoExportFailedSubtitle =>
|
||||||
|
'Save failed downloads to TXT file automatically';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get queueEmpty => 'Tidak ada unduhan dalam antrian';
|
String get queueEmpty => 'Tidak ada unduhan dalam antrian';
|
||||||
|
|
||||||
|
|||||||
@@ -679,6 +679,10 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String get setupIosEmptyFolderWarning =>
|
String get setupIosEmptyFolderWarning =>
|
||||||
'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.';
|
'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupIcloudNotSupported =>
|
||||||
|
'iCloud Drive is not supported. Please use the app Documents folder.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupDownloadInFlac => 'Spotify のトラックを FLAC でダウンロード';
|
String get setupDownloadInFlac => 'Spotify のトラックを FLAC でダウンロード';
|
||||||
|
|
||||||
@@ -1973,6 +1977,26 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get queueClearAllMessage => 'すべてのダウンロードを消去してもよろしいですか?';
|
String get queueClearAllMessage => 'すべてのダウンロードを消去してもよろしいですか?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailed => 'Export';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailedSuccess =>
|
||||||
|
'Failed downloads exported to TXT file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailedClear => 'Clear Failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailedError => 'Failed to export downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsAutoExportFailed => 'Auto-export failed downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsAutoExportFailedSubtitle =>
|
||||||
|
'Save failed downloads to TXT file automatically';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get queueEmpty => 'キューにダウンロードがありません';
|
String get queueEmpty => 'キューにダウンロードがありません';
|
||||||
|
|
||||||
|
|||||||
@@ -684,6 +684,10 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
String get setupIosEmptyFolderWarning =>
|
String get setupIosEmptyFolderWarning =>
|
||||||
'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.';
|
'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupIcloudNotSupported =>
|
||||||
|
'iCloud Drive is not supported. Please use the app Documents folder.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupDownloadInFlac => 'Download Spotify tracks in FLAC';
|
String get setupDownloadInFlac => 'Download Spotify tracks in FLAC';
|
||||||
|
|
||||||
@@ -1986,6 +1990,26 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
String get queueClearAllMessage =>
|
String get queueClearAllMessage =>
|
||||||
'Are you sure you want to clear all downloads?';
|
'Are you sure you want to clear all downloads?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailed => 'Export';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailedSuccess =>
|
||||||
|
'Failed downloads exported to TXT file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailedClear => 'Clear Failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailedError => 'Failed to export downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsAutoExportFailed => 'Auto-export failed downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsAutoExportFailedSubtitle =>
|
||||||
|
'Save failed downloads to TXT file automatically';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get queueEmpty => 'No downloads in queue';
|
String get queueEmpty => 'No downloads in queue';
|
||||||
|
|
||||||
|
|||||||
@@ -684,6 +684,10 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
String get setupIosEmptyFolderWarning =>
|
String get setupIosEmptyFolderWarning =>
|
||||||
'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.';
|
'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupIcloudNotSupported =>
|
||||||
|
'iCloud Drive is not supported. Please use the app Documents folder.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupDownloadInFlac => 'Download Spotify tracks in FLAC';
|
String get setupDownloadInFlac => 'Download Spotify tracks in FLAC';
|
||||||
|
|
||||||
@@ -1986,6 +1990,26 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
String get queueClearAllMessage =>
|
String get queueClearAllMessage =>
|
||||||
'Are you sure you want to clear all downloads?';
|
'Are you sure you want to clear all downloads?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailed => 'Export';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailedSuccess =>
|
||||||
|
'Failed downloads exported to TXT file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailedClear => 'Clear Failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailedError => 'Failed to export downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsAutoExportFailed => 'Auto-export failed downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsAutoExportFailedSubtitle =>
|
||||||
|
'Save failed downloads to TXT file automatically';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get queueEmpty => 'No downloads in queue';
|
String get queueEmpty => 'No downloads in queue';
|
||||||
|
|
||||||
|
|||||||
@@ -684,6 +684,10 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
String get setupIosEmptyFolderWarning =>
|
String get setupIosEmptyFolderWarning =>
|
||||||
'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.';
|
'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupIcloudNotSupported =>
|
||||||
|
'iCloud Drive is not supported. Please use the app Documents folder.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupDownloadInFlac => 'Download Spotify tracks in FLAC';
|
String get setupDownloadInFlac => 'Download Spotify tracks in FLAC';
|
||||||
|
|
||||||
@@ -1986,6 +1990,26 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
String get queueClearAllMessage =>
|
String get queueClearAllMessage =>
|
||||||
'Are you sure you want to clear all downloads?';
|
'Are you sure you want to clear all downloads?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailed => 'Export';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailedSuccess =>
|
||||||
|
'Failed downloads exported to TXT file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailedClear => 'Clear Failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailedError => 'Failed to export downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsAutoExportFailed => 'Auto-export failed downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsAutoExportFailedSubtitle =>
|
||||||
|
'Save failed downloads to TXT file automatically';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get queueEmpty => 'No downloads in queue';
|
String get queueEmpty => 'No downloads in queue';
|
||||||
|
|
||||||
|
|||||||
@@ -702,6 +702,10 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
String get setupIosEmptyFolderWarning =>
|
String get setupIosEmptyFolderWarning =>
|
||||||
'Ограничение iOS: пустые папки не могут быть выбраны. Выберите папку, содержащую хотя бы один файл.';
|
'Ограничение iOS: пустые папки не могут быть выбраны. Выберите папку, содержащую хотя бы один файл.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupIcloudNotSupported =>
|
||||||
|
'iCloud Drive is not supported. Please use the app Documents folder.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupDownloadInFlac => 'Скачать Spotify треки во FLAC';
|
String get setupDownloadInFlac => 'Скачать Spotify треки во FLAC';
|
||||||
|
|
||||||
@@ -2025,6 +2029,26 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
String get queueClearAllMessage =>
|
String get queueClearAllMessage =>
|
||||||
'Вы уверены, что хотите очистить все загрузки?';
|
'Вы уверены, что хотите очистить все загрузки?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailed => 'Export';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailedSuccess =>
|
||||||
|
'Failed downloads exported to TXT file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailedClear => 'Clear Failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailedError => 'Failed to export downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsAutoExportFailed => 'Auto-export failed downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsAutoExportFailedSubtitle =>
|
||||||
|
'Save failed downloads to TXT file automatically';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get queueEmpty => 'Нет загрузок в очереди';
|
String get queueEmpty => 'Нет загрузок в очереди';
|
||||||
|
|
||||||
|
|||||||
@@ -691,6 +691,10 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
String get setupIosEmptyFolderWarning =>
|
String get setupIosEmptyFolderWarning =>
|
||||||
'iOS\'un sınırlaması: Boş klasörler seçilemiyor. İçinde en az bir dosya bulunan bir klasör seçin.';
|
'iOS\'un sınırlaması: Boş klasörler seçilemiyor. İçinde en az bir dosya bulunan bir klasör seçin.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupIcloudNotSupported =>
|
||||||
|
'iCloud Drive is not supported. Please use the app Documents folder.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupDownloadInFlac => 'Spotify şarkılarını FLAC olarak indirin';
|
String get setupDownloadInFlac => 'Spotify şarkılarını FLAC olarak indirin';
|
||||||
|
|
||||||
@@ -2001,6 +2005,26 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
String get queueClearAllMessage =>
|
String get queueClearAllMessage =>
|
||||||
'Are you sure you want to clear all downloads?';
|
'Are you sure you want to clear all downloads?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailed => 'Export';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailedSuccess =>
|
||||||
|
'Failed downloads exported to TXT file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailedClear => 'Clear Failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailedError => 'Failed to export downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsAutoExportFailed => 'Auto-export failed downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsAutoExportFailedSubtitle =>
|
||||||
|
'Save failed downloads to TXT file automatically';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get queueEmpty => 'No downloads in queue';
|
String get queueEmpty => 'No downloads in queue';
|
||||||
|
|
||||||
|
|||||||
@@ -684,6 +684,10 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
String get setupIosEmptyFolderWarning =>
|
String get setupIosEmptyFolderWarning =>
|
||||||
'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.';
|
'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupIcloudNotSupported =>
|
||||||
|
'iCloud Drive is not supported. Please use the app Documents folder.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupDownloadInFlac => 'Download Spotify tracks in FLAC';
|
String get setupDownloadInFlac => 'Download Spotify tracks in FLAC';
|
||||||
|
|
||||||
@@ -1986,6 +1990,26 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
String get queueClearAllMessage =>
|
String get queueClearAllMessage =>
|
||||||
'Are you sure you want to clear all downloads?';
|
'Are you sure you want to clear all downloads?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailed => 'Export';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailedSuccess =>
|
||||||
|
'Failed downloads exported to TXT file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailedClear => 'Clear Failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailedError => 'Failed to export downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsAutoExportFailed => 'Auto-export failed downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsAutoExportFailedSubtitle =>
|
||||||
|
'Save failed downloads to TXT file automatically';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get queueEmpty => 'No downloads in queue';
|
String get queueEmpty => 'No downloads in queue';
|
||||||
|
|
||||||
|
|||||||
+16
-2
@@ -481,8 +481,10 @@
|
|||||||
"@setupChooseFromFiles": {"description": "iOS file picker option"},
|
"@setupChooseFromFiles": {"description": "iOS file picker option"},
|
||||||
"setupChooseFromFilesSubtitle": "Select iCloud or other location",
|
"setupChooseFromFilesSubtitle": "Select iCloud or other location",
|
||||||
"@setupChooseFromFilesSubtitle": {"description": "Subtitle for file picker"},
|
"@setupChooseFromFilesSubtitle": {"description": "Subtitle for file picker"},
|
||||||
"setupIosEmptyFolderWarning": "iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.",
|
"setupIosEmptyFolderWarning": "iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.",
|
||||||
"@setupIosEmptyFolderWarning": {"description": "iOS folder selection warning"},
|
"@setupIosEmptyFolderWarning": {"description": "iOS folder selection warning"},
|
||||||
|
"setupIcloudNotSupported": "iCloud Drive is not supported. Please use the app Documents folder.",
|
||||||
|
"@setupIcloudNotSupported": {"description": "Error when user selects iCloud Drive on iOS"},
|
||||||
"setupDownloadInFlac": "Download Spotify tracks in FLAC",
|
"setupDownloadInFlac": "Download Spotify tracks in FLAC",
|
||||||
"@setupDownloadInFlac": {"description": "App tagline in setup"},
|
"@setupDownloadInFlac": {"description": "App tagline in setup"},
|
||||||
"setupStepStorage": "Storage",
|
"setupStepStorage": "Storage",
|
||||||
@@ -1458,10 +1460,22 @@
|
|||||||
|
|
||||||
"queueTitle": "Download Queue",
|
"queueTitle": "Download Queue",
|
||||||
"@queueTitle": {"description": "Queue screen title"},
|
"@queueTitle": {"description": "Queue screen title"},
|
||||||
"queueClearAll": "Clear All",
|
"queueClearAll": "Clear All",
|
||||||
"@queueClearAll": {"description": "Button - clear all queue items"},
|
"@queueClearAll": {"description": "Button - clear all queue items"},
|
||||||
"queueClearAllMessage": "Are you sure you want to clear all downloads?",
|
"queueClearAllMessage": "Are you sure you want to clear all downloads?",
|
||||||
"@queueClearAllMessage": {"description": "Clear queue confirmation"},
|
"@queueClearAllMessage": {"description": "Clear queue confirmation"},
|
||||||
|
"queueExportFailed": "Export",
|
||||||
|
"@queueExportFailed": {"description": "Button - export failed downloads to TXT"},
|
||||||
|
"queueExportFailedSuccess": "Failed downloads exported to TXT file",
|
||||||
|
"@queueExportFailedSuccess": {"description": "Success message after exporting failed downloads"},
|
||||||
|
"queueExportFailedClear": "Clear Failed",
|
||||||
|
"@queueExportFailedClear": {"description": "Action to clear failed downloads after export"},
|
||||||
|
"queueExportFailedError": "Failed to export downloads",
|
||||||
|
"@queueExportFailedError": {"description": "Error message when export fails"},
|
||||||
|
"settingsAutoExportFailed": "Auto-export failed downloads",
|
||||||
|
"@settingsAutoExportFailed": {"description": "Setting toggle for auto-export"},
|
||||||
|
"settingsAutoExportFailedSubtitle": "Save failed downloads to TXT file automatically",
|
||||||
|
"@settingsAutoExportFailedSubtitle": {"description": "Subtitle for auto-export setting"},
|
||||||
"queueEmpty": "No downloads in queue",
|
"queueEmpty": "No downloads in queue",
|
||||||
"@queueEmpty": {"description": "Empty queue state title"},
|
"@queueEmpty": {"description": "Empty queue state title"},
|
||||||
"queueEmptySubtitle": "Add tracks from the home screen",
|
"queueEmptySubtitle": "Add tracks from the home screen",
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_initializeExtensions();
|
_initializeExtensions();
|
||||||
// Trigger history provider initialization without subscribing to updates.
|
|
||||||
ref.read(downloadHistoryProvider);
|
ref.read(downloadHistoryProvider);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,11 +31,10 @@ class AppSettings {
|
|||||||
final String albumFolderStructure;
|
final String albumFolderStructure;
|
||||||
final bool showExtensionStore;
|
final bool showExtensionStore;
|
||||||
final String locale;
|
final String locale;
|
||||||
final bool enableLossyOption;
|
|
||||||
final String lossyFormat;
|
|
||||||
final String lossyBitrate; // e.g., 'mp3_320', 'mp3_256', 'mp3_192', 'mp3_128', 'opus_128', 'opus_96', 'opus_64'
|
|
||||||
final String lyricsMode;
|
final String lyricsMode;
|
||||||
|
final String tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320' or 'opus_128'
|
||||||
final bool useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
|
final bool useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
|
||||||
|
final bool autoExportFailedDownloads; // Auto export failed downloads to TXT file
|
||||||
|
|
||||||
const AppSettings({
|
const AppSettings({
|
||||||
this.defaultService = 'tidal',
|
this.defaultService = 'tidal',
|
||||||
@@ -65,11 +64,10 @@ class AppSettings {
|
|||||||
this.albumFolderStructure = 'artist_album',
|
this.albumFolderStructure = 'artist_album',
|
||||||
this.showExtensionStore = true,
|
this.showExtensionStore = true,
|
||||||
this.locale = 'system',
|
this.locale = 'system',
|
||||||
this.enableLossyOption = false,
|
|
||||||
this.lossyFormat = 'mp3',
|
|
||||||
this.lossyBitrate = 'mp3_320',
|
|
||||||
this.lyricsMode = 'embed',
|
this.lyricsMode = 'embed',
|
||||||
|
this.tidalHighFormat = 'mp3_320',
|
||||||
this.useAllFilesAccess = false,
|
this.useAllFilesAccess = false,
|
||||||
|
this.autoExportFailedDownloads = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
AppSettings copyWith({
|
AppSettings copyWith({
|
||||||
@@ -101,11 +99,10 @@ class AppSettings {
|
|||||||
String? albumFolderStructure,
|
String? albumFolderStructure,
|
||||||
bool? showExtensionStore,
|
bool? showExtensionStore,
|
||||||
String? locale,
|
String? locale,
|
||||||
bool? enableLossyOption,
|
|
||||||
String? lossyFormat,
|
|
||||||
String? lossyBitrate,
|
|
||||||
String? lyricsMode,
|
String? lyricsMode,
|
||||||
|
String? tidalHighFormat,
|
||||||
bool? useAllFilesAccess,
|
bool? useAllFilesAccess,
|
||||||
|
bool? autoExportFailedDownloads,
|
||||||
}) {
|
}) {
|
||||||
return AppSettings(
|
return AppSettings(
|
||||||
defaultService: defaultService ?? this.defaultService,
|
defaultService: defaultService ?? this.defaultService,
|
||||||
@@ -135,11 +132,10 @@ class AppSettings {
|
|||||||
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
|
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
|
||||||
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
|
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
|
||||||
locale: locale ?? this.locale,
|
locale: locale ?? this.locale,
|
||||||
enableLossyOption: enableLossyOption ?? this.enableLossyOption,
|
|
||||||
lossyFormat: lossyFormat ?? this.lossyFormat,
|
|
||||||
lossyBitrate: lossyBitrate ?? this.lossyBitrate,
|
|
||||||
lyricsMode: lyricsMode ?? this.lyricsMode,
|
lyricsMode: lyricsMode ?? this.lyricsMode,
|
||||||
|
tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat,
|
||||||
useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess,
|
useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess,
|
||||||
|
autoExportFailedDownloads: autoExportFailedDownloads ?? this.autoExportFailedDownloads,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,11 +36,11 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
|||||||
json['albumFolderStructure'] as String? ?? 'artist_album',
|
json['albumFolderStructure'] as String? ?? 'artist_album',
|
||||||
showExtensionStore: json['showExtensionStore'] as bool? ?? true,
|
showExtensionStore: json['showExtensionStore'] as bool? ?? true,
|
||||||
locale: json['locale'] as String? ?? 'system',
|
locale: json['locale'] as String? ?? 'system',
|
||||||
enableLossyOption: json['enableLossyOption'] as bool? ?? false,
|
|
||||||
lossyFormat: json['lossyFormat'] as String? ?? 'mp3',
|
|
||||||
lossyBitrate: json['lossyBitrate'] as String? ?? 'mp3_320',
|
|
||||||
lyricsMode: json['lyricsMode'] as String? ?? 'embed',
|
lyricsMode: json['lyricsMode'] as String? ?? 'embed',
|
||||||
|
tidalHighFormat: json['tidalHighFormat'] as String? ?? 'mp3_320',
|
||||||
useAllFilesAccess: json['useAllFilesAccess'] as bool? ?? false,
|
useAllFilesAccess: json['useAllFilesAccess'] as bool? ?? false,
|
||||||
|
autoExportFailedDownloads:
|
||||||
|
json['autoExportFailedDownloads'] as bool? ?? false,
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||||
@@ -72,9 +72,8 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
|||||||
'albumFolderStructure': instance.albumFolderStructure,
|
'albumFolderStructure': instance.albumFolderStructure,
|
||||||
'showExtensionStore': instance.showExtensionStore,
|
'showExtensionStore': instance.showExtensionStore,
|
||||||
'locale': instance.locale,
|
'locale': instance.locale,
|
||||||
'enableLossyOption': instance.enableLossyOption,
|
|
||||||
'lossyFormat': instance.lossyFormat,
|
|
||||||
'lossyBitrate': instance.lossyBitrate,
|
|
||||||
'lyricsMode': instance.lyricsMode,
|
'lyricsMode': instance.lyricsMode,
|
||||||
|
'tidalHighFormat': instance.tidalHighFormat,
|
||||||
'useAllFilesAccess': instance.useAllFilesAccess,
|
'useAllFilesAccess': instance.useAllFilesAccess,
|
||||||
|
'autoExportFailedDownloads': instance.autoExportFailedDownloads,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -150,15 +150,12 @@ class DownloadHistoryState {
|
|||||||
.map((item) => MapEntry(item.isrc!, item)),
|
.map((item) => MapEntry(item.isrc!, item)),
|
||||||
);
|
);
|
||||||
|
|
||||||
/// O(1) check if spotify_id exists
|
|
||||||
bool isDownloaded(String spotifyId) =>
|
bool isDownloaded(String spotifyId) =>
|
||||||
_downloadedSpotifyIds.contains(spotifyId);
|
_downloadedSpotifyIds.contains(spotifyId);
|
||||||
|
|
||||||
/// O(1) lookup by spotify_id
|
|
||||||
DownloadHistoryItem? getBySpotifyId(String spotifyId) =>
|
DownloadHistoryItem? getBySpotifyId(String spotifyId) =>
|
||||||
_bySpotifyId[spotifyId];
|
_bySpotifyId[spotifyId];
|
||||||
|
|
||||||
/// O(1) lookup by ISRC
|
|
||||||
DownloadHistoryItem? getByIsrc(String isrc) =>
|
DownloadHistoryItem? getByIsrc(String isrc) =>
|
||||||
_byIsrc[isrc];
|
_byIsrc[isrc];
|
||||||
|
|
||||||
@@ -177,7 +174,6 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
|||||||
return DownloadHistoryState();
|
return DownloadHistoryState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Synchronously schedule load - ensures it runs before any UI renders
|
|
||||||
void _loadFromDatabaseSync() {
|
void _loadFromDatabaseSync() {
|
||||||
if (_isLoaded) return;
|
if (_isLoaded) return;
|
||||||
_isLoaded = true;
|
_isLoaded = true;
|
||||||
@@ -193,7 +189,6 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
|||||||
_historyLog.i('Migrated history from SharedPreferences to SQLite');
|
_historyLog.i('Migrated history from SharedPreferences to SQLite');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Migrate iOS paths if container UUID changed after app update
|
|
||||||
if (Platform.isIOS) {
|
if (Platform.isIOS) {
|
||||||
final pathsMigrated = await _db.migrateIosContainerPaths();
|
final pathsMigrated = await _db.migrateIosContainerPaths();
|
||||||
if (pathsMigrated) {
|
if (pathsMigrated) {
|
||||||
@@ -264,12 +259,10 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
|||||||
return state.getBySpotifyId(spotifyId);
|
return state.getBySpotifyId(spotifyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// O(1) lookup by ISRC
|
|
||||||
DownloadHistoryItem? getByIsrc(String isrc) {
|
DownloadHistoryItem? getByIsrc(String isrc) {
|
||||||
return state.getByIsrc(isrc);
|
return state.getByIsrc(isrc);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Async version with database lookup (for cases where in-memory might be stale)
|
|
||||||
Future<DownloadHistoryItem?> getBySpotifyIdAsync(String spotifyId) async {
|
Future<DownloadHistoryItem?> getBySpotifyIdAsync(String spotifyId) async {
|
||||||
final inMemory = state.getBySpotifyId(spotifyId);
|
final inMemory = state.getBySpotifyId(spotifyId);
|
||||||
if (inMemory != null) return inMemory;
|
if (inMemory != null) return inMemory;
|
||||||
@@ -286,7 +279,6 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get database stats for debugging
|
|
||||||
Future<int> getDatabaseCount() async {
|
Future<int> getDatabaseCount() async {
|
||||||
return await _db.getCount();
|
return await _db.getCount();
|
||||||
}
|
}
|
||||||
@@ -722,7 +714,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
final isSingle = track.isSingle;
|
final isSingle = track.isSingle;
|
||||||
final artistName = _sanitizeFolderName(albumArtist);
|
final artistName = _sanitizeFolderName(albumArtist);
|
||||||
|
|
||||||
// New option: Singles folder inside Artist folder
|
|
||||||
if (albumFolderStructure == 'artist_album_singles') {
|
if (albumFolderStructure == 'artist_album_singles') {
|
||||||
if (isSingle) {
|
if (isSingle) {
|
||||||
final singlesPath = '$baseDir${Platform.pathSeparator}$artistName${Platform.pathSeparator}Singles';
|
final singlesPath = '$baseDir${Platform.pathSeparator}$artistName${Platform.pathSeparator}Singles';
|
||||||
@@ -736,7 +727,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Existing behavior: Separate Albums/ and Singles/ at root
|
|
||||||
if (isSingle) {
|
if (isSingle) {
|
||||||
final singlesPath = '$baseDir${Platform.pathSeparator}Singles';
|
final singlesPath = '$baseDir${Platform.pathSeparator}Singles';
|
||||||
await _ensureDirExists(singlesPath, label: 'Singles folder');
|
await _ensureDirExists(singlesPath, label: 'Singles folder');
|
||||||
@@ -804,7 +794,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract year from release date (format: "2005-06-13" or "2005")
|
|
||||||
String? _extractYear(String? releaseDate) {
|
String? _extractYear(String? releaseDate) {
|
||||||
if (releaseDate == null || releaseDate.isEmpty) return null;
|
if (releaseDate == null || releaseDate.isEmpty) return null;
|
||||||
final match = _yearRegex.firstMatch(releaseDate);
|
final match = _yearRegex.firstMatch(releaseDate);
|
||||||
@@ -1023,12 +1012,74 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void removeItem(String id) {
|
void removeItem(String id) {
|
||||||
final items = state.items.where((item) => item.id != id).toList();
|
final items = state.items.where((item) => item.id != id).toList();
|
||||||
state = state.copyWith(items: items);
|
state = state.copyWith(items: items);
|
||||||
_saveQueueToStorage();
|
_saveQueueToStorage();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Export failed downloads to a TXT file
|
||||||
|
/// Returns the file path if successful, null otherwise
|
||||||
|
Future<String?> exportFailedDownloads() async {
|
||||||
|
final failedItems = state.items
|
||||||
|
.where((item) => item.status == DownloadStatus.failed)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (failedItems.isEmpty) {
|
||||||
|
_log.d('No failed downloads to export');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final buffer = StringBuffer();
|
||||||
|
buffer.writeln('# SpotiFLAC Failed Downloads');
|
||||||
|
buffer.writeln('# Exported: ${DateTime.now().toIso8601String()}');
|
||||||
|
buffer.writeln('# Total: ${failedItems.length} tracks');
|
||||||
|
buffer.writeln('#');
|
||||||
|
buffer.writeln('# Format: Track - Artist | Spotify URL | Error');
|
||||||
|
buffer.writeln('');
|
||||||
|
|
||||||
|
for (final item in failedItems) {
|
||||||
|
final track = item.track;
|
||||||
|
final spotifyUrl = track.id.startsWith('deezer:')
|
||||||
|
? 'https://www.deezer.com/track/${track.id.substring(7)}'
|
||||||
|
: 'https://open.spotify.com/track/${track.id}';
|
||||||
|
final error = item.error ?? 'Unknown error';
|
||||||
|
buffer.writeln('${track.name} - ${track.artistName} | $spotifyUrl | $error');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to download directory
|
||||||
|
String exportDir = state.outputDir;
|
||||||
|
if (exportDir.isEmpty) {
|
||||||
|
final dir = await getApplicationDocumentsDirectory();
|
||||||
|
exportDir = dir.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
final timestamp = DateTime.now().toIso8601String().replaceAll(':', '-').split('.').first;
|
||||||
|
final fileName = 'failed_downloads_$timestamp.txt';
|
||||||
|
final filePath = '$exportDir/$fileName';
|
||||||
|
|
||||||
|
final file = File(filePath);
|
||||||
|
await file.writeAsString(buffer.toString());
|
||||||
|
|
||||||
|
_log.i('Exported ${failedItems.length} failed downloads to: $filePath');
|
||||||
|
return filePath;
|
||||||
|
} catch (e) {
|
||||||
|
_log.e('Failed to export failed downloads: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all failed downloads from queue
|
||||||
|
void clearFailedDownloads() {
|
||||||
|
final items = state.items
|
||||||
|
.where((item) => item.status != DownloadStatus.failed)
|
||||||
|
.toList();
|
||||||
|
state = state.copyWith(items: items);
|
||||||
|
_saveQueueToStorage();
|
||||||
|
_log.d('Cleared failed downloads from queue');
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _runPostProcessingHooks(String filePath, Track track) async {
|
Future<void> _runPostProcessingHooks(String filePath, Track track) async {
|
||||||
try {
|
try {
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
@@ -1075,7 +1126,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Same logic as Go backend cover.go
|
|
||||||
String _upgradeToMaxQualityCover(String coverUrl) {
|
String _upgradeToMaxQualityCover(String coverUrl) {
|
||||||
const spotifySize300 = 'ab67616d00001e02';
|
const spotifySize300 = 'ab67616d00001e02';
|
||||||
const spotifySize640 = 'ab67616d0000b273';
|
const spotifySize640 = 'ab67616d0000b273';
|
||||||
@@ -1192,7 +1242,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
durationMs: durationMs,
|
durationMs: durationMs,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Skip instrumental tracks (no lyrics to embed)
|
|
||||||
if (lrcContent.isNotEmpty && lrcContent != '[instrumental:true]') {
|
if (lrcContent.isNotEmpty && lrcContent != '[instrumental:true]') {
|
||||||
metadata['LYRICS'] = lrcContent;
|
metadata['LYRICS'] = lrcContent;
|
||||||
metadata['UNSYNCEDLYRICS'] = lrcContent;
|
metadata['UNSYNCEDLYRICS'] = lrcContent;
|
||||||
@@ -1323,7 +1372,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
|
|
||||||
_log.d('MP3 Metadata map content: $metadata');
|
_log.d('MP3 Metadata map content: $metadata');
|
||||||
|
|
||||||
if (settings.embedLyrics) {
|
final lyricsMode = settings.lyricsMode;
|
||||||
|
final shouldEmbed = lyricsMode == 'embed' || lyricsMode == 'both';
|
||||||
|
final shouldSaveExternal = lyricsMode == 'external' || lyricsMode == 'both';
|
||||||
|
|
||||||
|
if (settings.embedLyrics && (shouldEmbed || shouldSaveExternal)) {
|
||||||
try {
|
try {
|
||||||
final durationMs = track.duration * 1000;
|
final durationMs = track.duration * 1000;
|
||||||
|
|
||||||
@@ -1336,12 +1389,24 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (lrcContent.isNotEmpty) {
|
if (lrcContent.isNotEmpty) {
|
||||||
metadata['LYRICS'] = lrcContent;
|
if (shouldEmbed) {
|
||||||
metadata['UNSYNCEDLYRICS'] = lrcContent;
|
metadata['LYRICS'] = lrcContent;
|
||||||
_log.d('Lyrics fetched for MP3 embedding (${lrcContent.length} chars)');
|
metadata['UNSYNCEDLYRICS'] = lrcContent;
|
||||||
|
_log.d('Lyrics fetched for MP3 embedding (${lrcContent.length} chars)');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldSaveExternal) {
|
||||||
|
try {
|
||||||
|
final lrcPath = mp3Path.replaceAll(RegExp(r'\.mp3$', caseSensitive: false), '.lrc');
|
||||||
|
await File(lrcPath).writeAsString(lrcContent);
|
||||||
|
_log.d('External LRC file saved: $lrcPath');
|
||||||
|
} catch (e) {
|
||||||
|
_log.w('Failed to save external LRC file: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_log.w('Failed to fetch lyrics for MP3 embedding: $e');
|
_log.w('Failed to fetch lyrics for MP3: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1461,7 +1526,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
|
|
||||||
_log.d('Opus Metadata map content: $metadata');
|
_log.d('Opus Metadata map content: $metadata');
|
||||||
|
|
||||||
if (settings.embedLyrics) {
|
// Handle lyrics based on lyricsMode setting
|
||||||
|
final lyricsMode = settings.lyricsMode;
|
||||||
|
final shouldEmbed = lyricsMode == 'embed' || lyricsMode == 'both';
|
||||||
|
final shouldSaveExternal = lyricsMode == 'external' || lyricsMode == 'both';
|
||||||
|
|
||||||
|
if (settings.embedLyrics && (shouldEmbed || shouldSaveExternal)) {
|
||||||
try {
|
try {
|
||||||
final durationMs = track.duration * 1000;
|
final durationMs = track.duration * 1000;
|
||||||
|
|
||||||
@@ -1474,11 +1544,25 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (lrcContent.isNotEmpty) {
|
if (lrcContent.isNotEmpty) {
|
||||||
metadata['LYRICS'] = lrcContent;
|
// Embed lyrics in file metadata if mode is 'embed' or 'both'
|
||||||
_log.d('Lyrics fetched for Opus embedding (${lrcContent.length} chars)');
|
if (shouldEmbed) {
|
||||||
|
metadata['LYRICS'] = lrcContent;
|
||||||
|
_log.d('Lyrics fetched for Opus embedding (${lrcContent.length} chars)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save external LRC file if mode is 'external' or 'both'
|
||||||
|
if (shouldSaveExternal) {
|
||||||
|
try {
|
||||||
|
final lrcPath = opusPath.replaceAll(RegExp(r'\.opus$', caseSensitive: false), '.lrc');
|
||||||
|
await File(lrcPath).writeAsString(lrcContent);
|
||||||
|
_log.d('External LRC file saved: $lrcPath');
|
||||||
|
} catch (e) {
|
||||||
|
_log.w('Failed to save external LRC file: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_log.w('Failed to fetch lyrics for Opus embedding: $e');
|
_log.w('Failed to fetch lyrics for Opus: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1542,11 +1626,28 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.outputDir.isEmpty) {
|
if (state.outputDir.isEmpty) {
|
||||||
_log.d('Output dir empty, initializing...');
|
_log.d('Output dir empty, initializing...');
|
||||||
await _initOutputDir();
|
await _initOutputDir();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// iOS: Validate that outputDir is writable (not iCloud Drive which Go can't access)
|
||||||
|
if (Platform.isIOS && state.outputDir.isNotEmpty) {
|
||||||
|
final isICloudPath = state.outputDir.contains('Mobile Documents') ||
|
||||||
|
state.outputDir.contains('CloudDocs') ||
|
||||||
|
state.outputDir.contains('com~apple~CloudDocs');
|
||||||
|
if (isICloudPath) {
|
||||||
|
_log.w('iOS: iCloud Drive path detected, falling back to app Documents folder');
|
||||||
|
_log.w('Go backend cannot write to iCloud Drive due to iOS sandboxing');
|
||||||
|
final dir = await getApplicationDocumentsDirectory();
|
||||||
|
final musicDir = Directory('${dir.path}/SpotiFLAC');
|
||||||
|
if (!await musicDir.exists()) {
|
||||||
|
await musicDir.create(recursive: true);
|
||||||
|
}
|
||||||
|
state = state.copyWith(outputDir: musicDir.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (state.outputDir.isEmpty) {
|
if (state.outputDir.isEmpty) {
|
||||||
_log.d('Using fallback directory...');
|
_log.d('Using fallback directory...');
|
||||||
final dir = await getApplicationDocumentsDirectory();
|
final dir = await getApplicationDocumentsDirectory();
|
||||||
@@ -1587,7 +1688,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
_downloadCount = 0;
|
_downloadCount = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
_log.i(
|
_log.i(
|
||||||
'Queue stats - completed: $_completedInSession, failed: $_failedInSession, totalAtStart: $_totalQueuedAtStart',
|
'Queue stats - completed: $_completedInSession, failed: $_failedInSession, totalAtStart: $_totalQueuedAtStart',
|
||||||
);
|
);
|
||||||
if (_totalQueuedAtStart > 0) {
|
if (_totalQueuedAtStart > 0) {
|
||||||
@@ -1595,6 +1696,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
completedCount: _completedInSession,
|
completedCount: _completedInSession,
|
||||||
failedCount: _failedInSession,
|
failedCount: _failedInSession,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Auto-export failed downloads if enabled
|
||||||
|
final settings = ref.read(settingsProvider);
|
||||||
|
if (settings.autoExportFailedDownloads && _failedInSession > 0) {
|
||||||
|
final exportPath = await exportFailedDownloads();
|
||||||
|
if (exportPath != null) {
|
||||||
|
_log.i('Auto-exported failed downloads to: $exportPath');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_log.i('Queue processing finished');
|
_log.i('Queue processing finished');
|
||||||
@@ -1804,12 +1914,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
final quality = item.qualityOverride ?? state.audioQuality;
|
final quality = item.qualityOverride ?? state.audioQuality;
|
||||||
|
|
||||||
// For LOSSY, we need to download FLAC first then convert
|
|
||||||
// Servers don't support lossy quality directly
|
|
||||||
final downloadQuality = quality == 'LOSSY' ? 'LOSSLESS' : quality;
|
|
||||||
|
|
||||||
// Fetch extended metadata (genre, label) from Deezer if available
|
|
||||||
String? genre;
|
String? genre;
|
||||||
String? label;
|
String? label;
|
||||||
|
|
||||||
@@ -1858,10 +1963,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
if (useExtensions) {
|
if (useExtensions) {
|
||||||
_log.d('Using extension providers for download');
|
_log.d('Using extension providers for download');
|
||||||
_log.d(
|
_log.d(
|
||||||
'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}${quality == 'LOSSY' ? ' (downloading as LOSSLESS for conversion)' : ''}',
|
'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}',
|
||||||
);
|
);
|
||||||
_log.d('Output dir: $outputDir');
|
_log.d('Output dir: $outputDir');
|
||||||
result = await PlatformBridge.downloadWithExtensions(
|
result = await PlatformBridge.downloadWithExtensions(
|
||||||
isrc: trackToDownload.isrc ?? '',
|
isrc: trackToDownload.isrc ?? '',
|
||||||
spotifyId: trackToDownload.id,
|
spotifyId: trackToDownload.id,
|
||||||
trackName: trackToDownload.name,
|
trackName: trackToDownload.name,
|
||||||
@@ -1871,7 +1976,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
coverUrl: trackToDownload.coverUrl,
|
coverUrl: trackToDownload.coverUrl,
|
||||||
outputDir: outputDir,
|
outputDir: outputDir,
|
||||||
filenameFormat: state.filenameFormat,
|
filenameFormat: state.filenameFormat,
|
||||||
quality: downloadQuality,
|
quality: quality,
|
||||||
trackNumber: trackToDownload.trackNumber ?? 1,
|
trackNumber: trackToDownload.trackNumber ?? 1,
|
||||||
discNumber: trackToDownload.discNumber ?? 1,
|
discNumber: trackToDownload.discNumber ?? 1,
|
||||||
releaseDate: trackToDownload.releaseDate,
|
releaseDate: trackToDownload.releaseDate,
|
||||||
@@ -1881,11 +1986,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
genre: genre,
|
genre: genre,
|
||||||
label: label,
|
label: label,
|
||||||
lyricsMode: settings.lyricsMode,
|
lyricsMode: settings.lyricsMode,
|
||||||
|
preferredService: item.service,
|
||||||
);
|
);
|
||||||
} else if (state.autoFallback) {
|
} else if (state.autoFallback) {
|
||||||
_log.d('Using auto-fallback mode');
|
_log.d('Using auto-fallback mode');
|
||||||
_log.d(
|
_log.d(
|
||||||
'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}${quality == 'LOSSY' ? ' (downloading as LOSSLESS for conversion)' : ''}',
|
'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}',
|
||||||
);
|
);
|
||||||
_log.d('Output dir: $outputDir');
|
_log.d('Output dir: $outputDir');
|
||||||
result = await PlatformBridge.downloadWithFallback(
|
result = await PlatformBridge.downloadWithFallback(
|
||||||
@@ -1898,7 +2004,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
coverUrl: trackToDownload.coverUrl,
|
coverUrl: trackToDownload.coverUrl,
|
||||||
outputDir: outputDir,
|
outputDir: outputDir,
|
||||||
filenameFormat: state.filenameFormat,
|
filenameFormat: state.filenameFormat,
|
||||||
quality: downloadQuality,
|
quality: quality,
|
||||||
trackNumber: trackToDownload.trackNumber ?? 1,
|
trackNumber: trackToDownload.trackNumber ?? 1,
|
||||||
discNumber: trackToDownload.discNumber ?? 1,
|
discNumber: trackToDownload.discNumber ?? 1,
|
||||||
releaseDate: trackToDownload.releaseDate,
|
releaseDate: trackToDownload.releaseDate,
|
||||||
@@ -1921,7 +2027,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
coverUrl: trackToDownload.coverUrl,
|
coverUrl: trackToDownload.coverUrl,
|
||||||
outputDir: outputDir,
|
outputDir: outputDir,
|
||||||
filenameFormat: state.filenameFormat,
|
filenameFormat: state.filenameFormat,
|
||||||
quality: downloadQuality,
|
quality: quality,
|
||||||
trackNumber: trackToDownload.trackNumber ?? 1,
|
trackNumber: trackToDownload.trackNumber ?? 1,
|
||||||
discNumber: trackToDownload.discNumber ?? 1,
|
discNumber: trackToDownload.discNumber ?? 1,
|
||||||
releaseDate: trackToDownload.releaseDate,
|
releaseDate: trackToDownload.releaseDate,
|
||||||
@@ -1980,9 +2086,77 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (filePath != null && filePath.endsWith('.m4a')) {
|
if (filePath != null && filePath.endsWith('.m4a')) {
|
||||||
_log.d(
|
// For HIGH quality (Tidal AAC 320kbps), convert to MP3 or Opus
|
||||||
'M4A file detected (Hi-Res DASH stream), attempting conversion to FLAC...',
|
if (quality == 'HIGH') {
|
||||||
);
|
final tidalHighFormat = settings.tidalHighFormat;
|
||||||
|
_log.i('Tidal HIGH quality download, converting M4A to $tidalHighFormat...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
updateItemStatus(
|
||||||
|
item.id,
|
||||||
|
DownloadStatus.downloading,
|
||||||
|
progress: 0.95,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Convert M4A to the selected format
|
||||||
|
final format = tidalHighFormat.startsWith('opus') ? 'opus' : 'mp3';
|
||||||
|
final convertedPath = await FFmpegService.convertM4aToLossy(
|
||||||
|
filePath,
|
||||||
|
format: format,
|
||||||
|
bitrate: tidalHighFormat,
|
||||||
|
deleteOriginal: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (convertedPath != null) {
|
||||||
|
filePath = convertedPath;
|
||||||
|
final bitrateDisplay = tidalHighFormat.contains('_')
|
||||||
|
? '${tidalHighFormat.split('_').last}kbps'
|
||||||
|
: '320kbps';
|
||||||
|
actualQuality = '${format.toUpperCase()} $bitrateDisplay';
|
||||||
|
_log.i('Successfully converted M4A to $format: $convertedPath');
|
||||||
|
|
||||||
|
// Embed metadata
|
||||||
|
_log.i('Embedding metadata to $format...');
|
||||||
|
updateItemStatus(
|
||||||
|
item.id,
|
||||||
|
DownloadStatus.downloading,
|
||||||
|
progress: 0.99,
|
||||||
|
);
|
||||||
|
|
||||||
|
final backendGenre = result['genre'] as String?;
|
||||||
|
final backendLabel = result['label'] as String?;
|
||||||
|
final backendCopyright = result['copyright'] as String?;
|
||||||
|
|
||||||
|
if (format == 'mp3') {
|
||||||
|
await _embedMetadataToMp3(
|
||||||
|
convertedPath,
|
||||||
|
trackToDownload,
|
||||||
|
genre: backendGenre ?? genre,
|
||||||
|
label: backendLabel ?? label,
|
||||||
|
copyright: backendCopyright,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await _embedMetadataToOpus(
|
||||||
|
convertedPath,
|
||||||
|
trackToDownload,
|
||||||
|
genre: backendGenre ?? genre,
|
||||||
|
label: backendLabel ?? label,
|
||||||
|
copyright: backendCopyright,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_log.d('Metadata embedded successfully');
|
||||||
|
} else {
|
||||||
|
_log.w('M4A to $format conversion failed, keeping M4A file');
|
||||||
|
actualQuality = 'AAC 320kbps';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_log.w('M4A conversion process failed: $e, keeping M4A file');
|
||||||
|
actualQuality = 'AAC 320kbps';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_log.d(
|
||||||
|
'M4A file detected (Hi-Res DASH stream), attempting conversion to FLAC...',
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final file = File(filePath);
|
final file = File(filePath);
|
||||||
@@ -2084,6 +2258,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
_log.w('FFmpeg conversion process failed: $e, keeping M4A file');
|
_log.w('FFmpeg conversion process failed: $e, keeping M4A file');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final itemAfterDownload = state.items.firstWhere(
|
final itemAfterDownload = state.items.firstWhere(
|
||||||
@@ -2106,74 +2281,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (quality == 'LOSSY' && filePath != null && filePath.endsWith('.flac')) {
|
|
||||||
if (wasExisting) {
|
|
||||||
_log.i('Lossy requested but existing FLAC found - skipping conversion to preserve original file');
|
|
||||||
} else {
|
|
||||||
final lossyFormat = settings.lossyFormat;
|
|
||||||
final lossyBitrate = settings.lossyBitrate;
|
|
||||||
_log.i('Lossy quality selected, converting FLAC to $lossyFormat ($lossyBitrate)...');
|
|
||||||
updateItemStatus(
|
|
||||||
item.id,
|
|
||||||
DownloadStatus.downloading,
|
|
||||||
progress: 0.97,
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
final convertedPath = await FFmpegService.convertFlacToLossy(
|
|
||||||
filePath,
|
|
||||||
format: lossyFormat,
|
|
||||||
bitrate: lossyBitrate,
|
|
||||||
deleteOriginal: true,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (convertedPath != null) {
|
|
||||||
filePath = convertedPath;
|
|
||||||
// Extract bitrate for display (e.g., 'mp3_320' -> '320kbps')
|
|
||||||
final bitrateDisplay = lossyBitrate.contains('_')
|
|
||||||
? '${lossyBitrate.split('_').last}kbps'
|
|
||||||
: (lossyFormat == 'opus' ? '128kbps' : '320kbps');
|
|
||||||
actualQuality = '${lossyFormat.toUpperCase()} $bitrateDisplay';
|
|
||||||
_log.i('Successfully converted to $lossyFormat ($bitrateDisplay): $convertedPath');
|
|
||||||
|
|
||||||
// Embed metadata and cover for both MP3 and Opus
|
|
||||||
_log.i('Embedding metadata to $lossyFormat...');
|
|
||||||
updateItemStatus(
|
|
||||||
item.id,
|
|
||||||
DownloadStatus.downloading,
|
|
||||||
progress: 0.99,
|
|
||||||
);
|
|
||||||
|
|
||||||
final lossyBackendGenre = result['genre'] as String?;
|
|
||||||
final lossyBackendLabel = result['label'] as String?;
|
|
||||||
final lossyBackendCopyright = result['copyright'] as String?;
|
|
||||||
|
|
||||||
if (lossyFormat == 'mp3') {
|
|
||||||
await _embedMetadataToMp3(
|
|
||||||
convertedPath,
|
|
||||||
trackToDownload,
|
|
||||||
genre: lossyBackendGenre ?? genre,
|
|
||||||
label: lossyBackendLabel ?? label,
|
|
||||||
copyright: lossyBackendCopyright,
|
|
||||||
);
|
|
||||||
} else if (lossyFormat == 'opus') {
|
|
||||||
await _embedMetadataToOpus(
|
|
||||||
convertedPath,
|
|
||||||
trackToDownload,
|
|
||||||
genre: lossyBackendGenre ?? genre,
|
|
||||||
label: lossyBackendLabel ?? label,
|
|
||||||
copyright: lossyBackendCopyright,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
_log.w('$lossyFormat conversion failed, keeping FLAC file');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
_log.e('Lossy conversion error: $e, keeping FLAC file');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateItemStatus(
|
updateItemStatus(
|
||||||
item.id,
|
item.id,
|
||||||
DownloadStatus.completed,
|
DownloadStatus.completed,
|
||||||
|
|||||||
@@ -231,31 +231,20 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
_saveSettings();
|
_saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
void setEnableLossyOption(bool enabled) {
|
void setTidalHighFormat(String format) {
|
||||||
state = state.copyWith(enableLossyOption: enabled);
|
state = state.copyWith(tidalHighFormat: format);
|
||||||
// If Lossy is disabled and current quality is LOSSY, reset to LOSSLESS
|
|
||||||
if (!enabled && state.audioQuality == 'LOSSY') {
|
|
||||||
state = state.copyWith(audioQuality: 'LOSSLESS');
|
|
||||||
}
|
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
void setLossyFormat(String format) {
|
void setUseAllFilesAccess(bool enabled) {
|
||||||
state = state.copyWith(lossyFormat: format);
|
|
||||||
_saveSettings();
|
|
||||||
}
|
|
||||||
|
|
||||||
void setLossyBitrate(String bitrate) {
|
|
||||||
// Extract format from bitrate (e.g., 'mp3_320' -> 'mp3')
|
|
||||||
final format = bitrate.split('_').first;
|
|
||||||
state = state.copyWith(lossyBitrate: bitrate, lossyFormat: format);
|
|
||||||
_saveSettings();
|
|
||||||
}
|
|
||||||
|
|
||||||
void setUseAllFilesAccess(bool enabled) {
|
|
||||||
state = state.copyWith(useAllFilesAccess: enabled);
|
state = state.copyWith(useAllFilesAccess: enabled);
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setAutoExportFailedDownloads(bool enabled) {
|
||||||
|
state = state.copyWith(autoExportFailedDownloads: enabled);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
|
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
|
||||||
|
|||||||
@@ -80,7 +80,9 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
_scrollController.addListener(_onScroll);
|
_scrollController.addListener(_onScroll);
|
||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
final providerId = widget.albumId.startsWith('deezer:') ? 'deezer' : 'spotify';
|
// Use extensionId if available, otherwise detect from albumId prefix
|
||||||
|
final providerId = widget.extensionId ??
|
||||||
|
(widget.albumId.startsWith('deezer:') ? 'deezer' : 'spotify');
|
||||||
ref.read(recentAccessProvider.notifier).recordAlbumAccess(
|
ref.read(recentAccessProvider.notifier).recordAlbumAccess(
|
||||||
id: widget.albumId,
|
id: widget.albumId,
|
||||||
name: widget.albumName,
|
name: widget.albumName,
|
||||||
@@ -90,10 +92,15 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
_tracks = widget.tracks ?? _AlbumCache.get(widget.albumId);
|
// Use provided tracks if not empty, otherwise try cache
|
||||||
|
if (widget.tracks != null && widget.tracks!.isNotEmpty) {
|
||||||
|
_tracks = widget.tracks;
|
||||||
|
} else {
|
||||||
|
_tracks = _AlbumCache.get(widget.albumId);
|
||||||
|
}
|
||||||
_artistId = widget.artistId; // Use provided artist ID if available
|
_artistId = widget.artistId; // Use provided artist ID if available
|
||||||
|
|
||||||
if (_tracks == null) {
|
if (_tracks == null || _tracks!.isEmpty) {
|
||||||
_fetchTracks();
|
_fetchTracks();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1887,12 +1887,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
void _navigateToSearchAlbum(SearchAlbum album) {
|
void _navigateToSearchAlbum(SearchAlbum album) {
|
||||||
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
||||||
|
|
||||||
// Extract the numeric ID from "deezer:123" format
|
|
||||||
String albumId = album.id;
|
|
||||||
if (albumId.startsWith('deezer:')) {
|
|
||||||
albumId = albumId.substring(7);
|
|
||||||
}
|
|
||||||
|
|
||||||
ref.read(recentAccessProvider.notifier).recordAlbumAccess(
|
ref.read(recentAccessProvider.notifier).recordAlbumAccess(
|
||||||
id: album.id,
|
id: album.id,
|
||||||
name: album.name,
|
name: album.name,
|
||||||
@@ -1901,9 +1895,10 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
providerId: 'deezer',
|
providerId: 'deezer',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Keep the full ID with prefix (e.g., "deezer:123") for AlbumScreen to detect source
|
||||||
Navigator.push(context, MaterialPageRoute(
|
Navigator.push(context, MaterialPageRoute(
|
||||||
builder: (context) => AlbumScreen(
|
builder: (context) => AlbumScreen(
|
||||||
albumId: albumId,
|
albumId: album.id,
|
||||||
albumName: album.name,
|
albumName: album.name,
|
||||||
coverUrl: album.imageUrl,
|
coverUrl: album.imageUrl,
|
||||||
tracks: const [], // Will be fetched by AlbumScreen
|
tracks: const [], // Will be fetched by AlbumScreen
|
||||||
@@ -1914,12 +1909,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
void _navigateToSearchPlaylist(SearchPlaylist playlist) {
|
void _navigateToSearchPlaylist(SearchPlaylist playlist) {
|
||||||
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
||||||
|
|
||||||
// Extract the numeric ID from "deezer:123" format
|
|
||||||
String playlistId = playlist.id;
|
|
||||||
if (playlistId.startsWith('deezer:')) {
|
|
||||||
playlistId = playlistId.substring(7);
|
|
||||||
}
|
|
||||||
|
|
||||||
ref.read(recentAccessProvider.notifier).recordPlaylistAccess(
|
ref.read(recentAccessProvider.notifier).recordPlaylistAccess(
|
||||||
id: playlist.id,
|
id: playlist.id,
|
||||||
name: playlist.name,
|
name: playlist.name,
|
||||||
@@ -1928,12 +1917,13 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
providerId: 'deezer',
|
providerId: 'deezer',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Keep the full ID with prefix (e.g., "deezer:123") for PlaylistScreen to detect source
|
||||||
Navigator.push(context, MaterialPageRoute(
|
Navigator.push(context, MaterialPageRoute(
|
||||||
builder: (context) => PlaylistScreen(
|
builder: (context) => PlaylistScreen(
|
||||||
playlistName: playlist.name,
|
playlistName: playlist.name,
|
||||||
coverUrl: playlist.imageUrl,
|
coverUrl: playlist.imageUrl,
|
||||||
tracks: const [], // Will be fetched
|
tracks: const [], // Will be fetched
|
||||||
playlistId: playlistId,
|
playlistId: playlist.id,
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,10 +64,17 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final result = await PlatformBridge.getDeezerMetadata('playlist', widget.playlistId!);
|
// Extract numeric ID from "deezer:123" format
|
||||||
|
String playlistId = widget.playlistId!;
|
||||||
|
if (playlistId.startsWith('deezer:')) {
|
||||||
|
playlistId = playlistId.substring(7);
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = await PlatformBridge.getDeezerMetadata('playlist', playlistId);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
final trackList = result['tracks'] as List<dynamic>? ?? [];
|
// Go backend returns 'track_list' not 'tracks'
|
||||||
|
final trackList = result['track_list'] as List<dynamic>? ?? [];
|
||||||
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|||||||
@@ -806,7 +806,9 @@ final queueItems = ref.watch(downloadQueueProvider.select((s) => s.items));
|
|||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
|
_buildExportFailedButton(context, ref, colorScheme),
|
||||||
|
const SizedBox(width: 4),
|
||||||
_buildPauseResumeButton(context, ref, colorScheme),
|
_buildPauseResumeButton(context, ref, colorScheme),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
_buildClearAllButton(context, ref, colorScheme),
|
_buildClearAllButton(context, ref, colorScheme),
|
||||||
@@ -1194,7 +1196,7 @@ if (queueItems.isEmpty &&
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildClearAllButton(
|
Widget _buildClearAllButton(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
WidgetRef ref,
|
WidgetRef ref,
|
||||||
ColorScheme colorScheme,
|
ColorScheme colorScheme,
|
||||||
@@ -1210,6 +1212,60 @@ if (queueItems.isEmpty &&
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildExportFailedButton(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
ColorScheme colorScheme,
|
||||||
|
) {
|
||||||
|
final queueState = ref.watch(downloadQueueProvider);
|
||||||
|
final failedCount = queueState.failedCount;
|
||||||
|
|
||||||
|
if (failedCount == 0) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
return TextButton.icon(
|
||||||
|
onPressed: () => _exportFailedDownloads(context, ref),
|
||||||
|
icon: const Icon(Icons.file_download, size: 18),
|
||||||
|
label: Text(context.l10n.queueExportFailed),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
foregroundColor: colorScheme.tertiary,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _exportFailedDownloads(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
) async {
|
||||||
|
final filePath = await ref.read(downloadQueueProvider.notifier).exportFailedDownloads();
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
if (filePath != null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(context.l10n.queueExportFailedSuccess),
|
||||||
|
action: SnackBarAction(
|
||||||
|
label: context.l10n.queueExportFailedClear,
|
||||||
|
onPressed: () {
|
||||||
|
ref.read(downloadQueueProvider.notifier).clearFailedDownloads();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
duration: const Duration(seconds: 5),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(context.l10n.queueExportFailedError),
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _showClearAllDialog(
|
Future<void> _showClearAllDialog(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
WidgetRef ref,
|
WidgetRef ref,
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
|||||||
final topPadding = MediaQuery.of(context).padding.top;
|
final topPadding = MediaQuery.of(context).padding.top;
|
||||||
|
|
||||||
final isBuiltInService = _builtInServices.contains(settings.defaultService);
|
final isBuiltInService = _builtInServices.contains(settings.defaultService);
|
||||||
|
final isTidalService = settings.defaultService == 'tidal';
|
||||||
|
|
||||||
return PopScope(
|
return PopScope(
|
||||||
canPop: true,
|
canPop: true,
|
||||||
@@ -173,24 +174,6 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
|||||||
.read(settingsProvider.notifier)
|
.read(settingsProvider.notifier)
|
||||||
.setAskQualityBeforeDownload(value),
|
.setAskQualityBeforeDownload(value),
|
||||||
),
|
),
|
||||||
SettingsSwitchItem(
|
|
||||||
icon: Icons.audiotrack,
|
|
||||||
title: context.l10n.enableLossyOption,
|
|
||||||
subtitle: settings.enableLossyOption
|
|
||||||
? context.l10n.enableLossyOptionSubtitleOn
|
|
||||||
: context.l10n.enableLossyOptionSubtitleOff,
|
|
||||||
value: settings.enableLossyOption,
|
|
||||||
onChanged: (value) => ref
|
|
||||||
.read(settingsProvider.notifier)
|
|
||||||
.setEnableLossyOption(value),
|
|
||||||
),
|
|
||||||
if (settings.enableLossyOption)
|
|
||||||
SettingsItem(
|
|
||||||
icon: Icons.tune,
|
|
||||||
title: context.l10n.lossyFormat,
|
|
||||||
subtitle: _getLossyBitrateLabel(settings.lossyBitrate),
|
|
||||||
onTap: () => _showLossyBitratePicker(context, ref, settings.lossyBitrate),
|
|
||||||
),
|
|
||||||
if (!settings.askQualityBeforeDownload && isBuiltInService) ...[
|
if (!settings.askQualityBeforeDownload && isBuiltInService) ...[
|
||||||
_QualityOption(
|
_QualityOption(
|
||||||
title: context.l10n.qualityFlacLossless,
|
title: context.l10n.qualityFlacLossless,
|
||||||
@@ -215,18 +198,25 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
|||||||
onTap: () => ref
|
onTap: () => ref
|
||||||
.read(settingsProvider.notifier)
|
.read(settingsProvider.notifier)
|
||||||
.setAudioQuality('HI_RES_LOSSLESS'),
|
.setAudioQuality('HI_RES_LOSSLESS'),
|
||||||
showDivider: settings.enableLossyOption,
|
showDivider: isTidalService,
|
||||||
),
|
),
|
||||||
if (settings.enableLossyOption)
|
// Lossy 320kbps option (Tidal only) - downloads M4A, converts to MP3/Opus
|
||||||
|
if (isTidalService)
|
||||||
_QualityOption(
|
_QualityOption(
|
||||||
title: context.l10n.qualityLossy,
|
title: 'Lossy 320kbps',
|
||||||
subtitle: settings.lossyFormat == 'opus'
|
subtitle: _getTidalHighFormatLabel(settings.tidalHighFormat),
|
||||||
? context.l10n.qualityLossyOpusSubtitle
|
isSelected: settings.audioQuality == 'HIGH',
|
||||||
: context.l10n.qualityLossyMp3Subtitle,
|
|
||||||
isSelected: settings.audioQuality == 'LOSSY',
|
|
||||||
onTap: () => ref
|
onTap: () => ref
|
||||||
.read(settingsProvider.notifier)
|
.read(settingsProvider.notifier)
|
||||||
.setAudioQuality('LOSSY'),
|
.setAudioQuality('HIGH'),
|
||||||
|
showDivider: false,
|
||||||
|
),
|
||||||
|
if (isTidalService && settings.audioQuality == 'HIGH')
|
||||||
|
SettingsItem(
|
||||||
|
icon: Icons.tune,
|
||||||
|
title: 'Lossy Format',
|
||||||
|
subtitle: _getTidalHighFormatLabel(settings.tidalHighFormat),
|
||||||
|
onTap: () => _showTidalHighFormatPicker(context, ref, settings.tidalHighFormat),
|
||||||
showDivider: false,
|
showDivider: false,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -395,7 +385,27 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
||||||
|
|
||||||
|
// Auto Export Failed Downloads
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: SettingsGroup(
|
||||||
|
children: [
|
||||||
|
SettingsSwitchItem(
|
||||||
|
icon: Icons.file_download_outlined,
|
||||||
|
title: context.l10n.settingsAutoExportFailed,
|
||||||
|
subtitle: context.l10n.settingsAutoExportFailedSubtitle,
|
||||||
|
value: settings.autoExportFailedDownloads,
|
||||||
|
onChanged: (value) {
|
||||||
|
ref.read(settingsProvider.notifier).setAutoExportFailedDownloads(value);
|
||||||
|
},
|
||||||
|
showDivider: false,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||||
],
|
],
|
||||||
@@ -712,7 +722,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
|||||||
if (ctx.mounted) Navigator.pop(ctx);
|
if (ctx.mounted) Navigator.pop(ctx);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(Icons.cloud, color: colorScheme.onSurfaceVariant),
|
leading: Icon(Icons.cloud, color: colorScheme.onSurfaceVariant),
|
||||||
title: Text(context.l10n.setupChooseFromFiles),
|
title: Text(context.l10n.setupChooseFromFiles),
|
||||||
subtitle: Text(context.l10n.setupChooseFromFilesSubtitle),
|
subtitle: Text(context.l10n.setupChooseFromFilesSubtitle),
|
||||||
@@ -721,6 +731,24 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
|||||||
// Note: iOS requires folder to have at least one file to be selectable
|
// Note: iOS requires folder to have at least one file to be selectable
|
||||||
final result = await FilePicker.platform.getDirectoryPath();
|
final result = await FilePicker.platform.getDirectoryPath();
|
||||||
if (result != null) {
|
if (result != null) {
|
||||||
|
// iOS: Check if user selected iCloud Drive (not accessible by Go backend)
|
||||||
|
if (Platform.isIOS) {
|
||||||
|
final isICloudPath = result.contains('Mobile Documents') ||
|
||||||
|
result.contains('CloudDocs') ||
|
||||||
|
result.contains('com~apple~CloudDocs');
|
||||||
|
if (isICloudPath) {
|
||||||
|
if (ctx.mounted) {
|
||||||
|
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(context.l10n.setupIcloudNotSupported),
|
||||||
|
backgroundColor: Theme.of(ctx).colorScheme.error,
|
||||||
|
duration: const Duration(seconds: 4),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
ref
|
ref
|
||||||
.read(settingsProvider.notifier)
|
.read(settingsProvider.notifier)
|
||||||
.setDownloadDirectory(result);
|
.setDownloadDirectory(result);
|
||||||
@@ -858,28 +886,18 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _getLossyBitrateLabel(String bitrate) {
|
String _getTidalHighFormatLabel(String format) {
|
||||||
switch (bitrate) {
|
switch (format) {
|
||||||
case 'mp3_320':
|
case 'mp3_320':
|
||||||
return 'MP3 320kbps (Best)';
|
return 'MP3 320kbps';
|
||||||
case 'mp3_256':
|
|
||||||
return 'MP3 256kbps';
|
|
||||||
case 'mp3_192':
|
|
||||||
return 'MP3 192kbps';
|
|
||||||
case 'mp3_128':
|
|
||||||
return 'MP3 128kbps';
|
|
||||||
case 'opus_128':
|
case 'opus_128':
|
||||||
return 'Opus 128kbps (Best)';
|
return 'Opus 128kbps';
|
||||||
case 'opus_96':
|
|
||||||
return 'Opus 96kbps';
|
|
||||||
case 'opus_64':
|
|
||||||
return 'Opus 64kbps';
|
|
||||||
default:
|
default:
|
||||||
return 'MP3 320kbps';
|
return 'MP3 320kbps';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showLossyBitratePicker(
|
void _showTidalHighFormatPicker(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
WidgetRef ref,
|
WidgetRef ref,
|
||||||
String current,
|
String current,
|
||||||
@@ -888,130 +906,54 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
|||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||||
isScrollControlled: true,
|
|
||||||
shape: const RoundedRectangleBorder(
|
shape: const RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||||
),
|
),
|
||||||
builder: (context) => SafeArea(
|
builder: (context) => SafeArea(
|
||||||
child: SingleChildScrollView(
|
child: Column(
|
||||||
child: Column(
|
mainAxisSize: MainAxisSize.min,
|
||||||
mainAxisSize: MainAxisSize.min,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
children: [
|
||||||
children: [
|
Padding(
|
||||||
Padding(
|
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
child: Text(
|
||||||
child: Text(
|
'Lossy 320kbps Format',
|
||||||
context.l10n.lossyFormat,
|
style: Theme.of(
|
||||||
style: Theme.of(
|
context,
|
||||||
context,
|
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||||
|
child: Text(
|
||||||
|
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Padding(
|
),
|
||||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
ListTile(
|
||||||
child: Text(
|
leading: const Icon(Icons.audiotrack),
|
||||||
context.l10n.lossyFormatDescription,
|
title: const Text('MP3 320kbps'),
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
subtitle: const Text('Best compatibility, ~10MB per track'),
|
||||||
color: colorScheme.onSurfaceVariant,
|
trailing: current == 'mp3_320' ? Icon(Icons.check, color: colorScheme.primary) : null,
|
||||||
),
|
onTap: () {
|
||||||
),
|
ref.read(settingsProvider.notifier).setTidalHighFormat('mp3_320');
|
||||||
),
|
Navigator.pop(context);
|
||||||
// MP3 Section
|
},
|
||||||
Padding(
|
),
|
||||||
padding: const EdgeInsets.fromLTRB(24, 8, 24, 4),
|
ListTile(
|
||||||
child: Text(
|
leading: const Icon(Icons.graphic_eq),
|
||||||
'MP3',
|
title: const Text('Opus 128kbps'),
|
||||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
subtitle: const Text('Modern codec, ~4MB per track'),
|
||||||
color: colorScheme.primary,
|
trailing: current == 'opus_128' ? Icon(Icons.check, color: colorScheme.primary) : null,
|
||||||
fontWeight: FontWeight.bold,
|
onTap: () {
|
||||||
),
|
ref.read(settingsProvider.notifier).setTidalHighFormat('opus_128');
|
||||||
),
|
Navigator.pop(context);
|
||||||
),
|
},
|
||||||
ListTile(
|
),
|
||||||
leading: const Icon(Icons.audiotrack),
|
const SizedBox(height: 16),
|
||||||
title: const Text('320kbps'),
|
],
|
||||||
subtitle: const Text('Best quality, larger files'),
|
|
||||||
trailing: current == 'mp3_320' ? Icon(Icons.check, color: colorScheme.primary) : null,
|
|
||||||
onTap: () {
|
|
||||||
ref.read(settingsProvider.notifier).setLossyBitrate('mp3_320');
|
|
||||||
Navigator.pop(context);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.audiotrack),
|
|
||||||
title: const Text('256kbps'),
|
|
||||||
subtitle: const Text('High quality'),
|
|
||||||
trailing: current == 'mp3_256' ? Icon(Icons.check, color: colorScheme.primary) : null,
|
|
||||||
onTap: () {
|
|
||||||
ref.read(settingsProvider.notifier).setLossyBitrate('mp3_256');
|
|
||||||
Navigator.pop(context);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.audiotrack),
|
|
||||||
title: const Text('192kbps'),
|
|
||||||
subtitle: const Text('Good quality'),
|
|
||||||
trailing: current == 'mp3_192' ? Icon(Icons.check, color: colorScheme.primary) : null,
|
|
||||||
onTap: () {
|
|
||||||
ref.read(settingsProvider.notifier).setLossyBitrate('mp3_192');
|
|
||||||
Navigator.pop(context);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.audiotrack),
|
|
||||||
title: const Text('128kbps'),
|
|
||||||
subtitle: const Text('Smaller files'),
|
|
||||||
trailing: current == 'mp3_128' ? Icon(Icons.check, color: colorScheme.primary) : null,
|
|
||||||
onTap: () {
|
|
||||||
ref.read(settingsProvider.notifier).setLossyBitrate('mp3_128');
|
|
||||||
Navigator.pop(context);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const Divider(indent: 24, endIndent: 24),
|
|
||||||
// Opus Section
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(24, 8, 24, 4),
|
|
||||||
child: Text(
|
|
||||||
'Opus',
|
|
||||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
|
||||||
color: colorScheme.primary,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.graphic_eq),
|
|
||||||
title: const Text('128kbps'),
|
|
||||||
subtitle: const Text('Best quality, efficient codec'),
|
|
||||||
trailing: current == 'opus_128' ? Icon(Icons.check, color: colorScheme.primary) : null,
|
|
||||||
onTap: () {
|
|
||||||
ref.read(settingsProvider.notifier).setLossyBitrate('opus_128');
|
|
||||||
Navigator.pop(context);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.graphic_eq),
|
|
||||||
title: const Text('96kbps'),
|
|
||||||
subtitle: const Text('Good quality'),
|
|
||||||
trailing: current == 'opus_96' ? Icon(Icons.check, color: colorScheme.primary) : null,
|
|
||||||
onTap: () {
|
|
||||||
ref.read(settingsProvider.notifier).setLossyBitrate('opus_96');
|
|
||||||
Navigator.pop(context);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.graphic_eq),
|
|
||||||
title: const Text('64kbps'),
|
|
||||||
subtitle: const Text('Smallest files'),
|
|
||||||
trailing: current == 'opus_64' ? Icon(Icons.check, color: colorScheme.primary) : null,
|
|
||||||
onTap: () {
|
|
||||||
ref.read(settingsProvider.notifier).setLossyBitrate('opus_64');
|
|
||||||
Navigator.pop(context);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ import 'package:spotiflac_android/utils/logger.dart';
|
|||||||
|
|
||||||
final _log = AppLogger('FFmpeg');
|
final _log = AppLogger('FFmpeg');
|
||||||
|
|
||||||
/// FFmpeg service for audio conversion and remuxing
|
|
||||||
/// Uses ffmpeg_kit_flutter_new_audio plugin
|
|
||||||
class FFmpegService {
|
class FFmpegService {
|
||||||
static Future<FFmpegResult> _execute(String command) async {
|
static Future<FFmpegResult> _execute(String command) async {
|
||||||
try {
|
try {
|
||||||
@@ -48,6 +46,47 @@ class FFmpegService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<String?> convertM4aToLossy(
|
||||||
|
String inputPath, {
|
||||||
|
required String format,
|
||||||
|
String? bitrate,
|
||||||
|
bool deleteOriginal = true,
|
||||||
|
}) async {
|
||||||
|
String bitrateValue = format == 'opus' ? '128k' : '320k';
|
||||||
|
if (bitrate != null && bitrate.contains('_')) {
|
||||||
|
final parts = bitrate.split('_');
|
||||||
|
if (parts.length == 2) {
|
||||||
|
bitrateValue = '${parts[1]}k';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final extension = format == 'opus' ? '.opus' : '.mp3';
|
||||||
|
final outputPath = inputPath.replaceAll('.m4a', extension);
|
||||||
|
|
||||||
|
String command;
|
||||||
|
if (format == 'opus') {
|
||||||
|
command =
|
||||||
|
'-i "$inputPath" -codec:a libopus -b:a $bitrateValue -vbr on -compression_level 10 -map 0:a "$outputPath" -y';
|
||||||
|
} else {
|
||||||
|
command =
|
||||||
|
'-i "$inputPath" -codec:a libmp3lame -b:a $bitrateValue -map 0:a -id3v2_version 3 "$outputPath" -y';
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = await _execute(command);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
if (deleteOriginal) {
|
||||||
|
try {
|
||||||
|
await File(inputPath).delete();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
return outputPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
_log.e('M4A to $format conversion failed: ${result.output}');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
static Future<String?> convertFlacToMp3(
|
static Future<String?> convertFlacToMp3(
|
||||||
String inputPath, {
|
String inputPath, {
|
||||||
String bitrate = '320k',
|
String bitrate = '320k',
|
||||||
@@ -80,7 +119,6 @@ class FFmpegService {
|
|||||||
}) async {
|
}) async {
|
||||||
final outputPath = inputPath.replaceAll('.flac', '.opus');
|
final outputPath = inputPath.replaceAll('.flac', '.opus');
|
||||||
|
|
||||||
// Opus in OGG container with VBR
|
|
||||||
final command =
|
final command =
|
||||||
'-i "$inputPath" -codec:a libopus -b:a $bitrate -vbr on -compression_level 10 -map 0:a -map_metadata 0 "$outputPath" -y';
|
'-i "$inputPath" -codec:a libopus -b:a $bitrate -vbr on -compression_level 10 -map 0:a -map_metadata 0 "$outputPath" -y';
|
||||||
|
|
||||||
@@ -99,17 +137,13 @@ class FFmpegService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert FLAC to lossy format based on format parameter
|
|
||||||
/// format: 'mp3' or 'opus'
|
|
||||||
/// bitrate: e.g., 'mp3_320', 'opus_128' - extracts the kbps value
|
|
||||||
static Future<String?> convertFlacToLossy(
|
static Future<String?> convertFlacToLossy(
|
||||||
String inputPath, {
|
String inputPath, {
|
||||||
required String format,
|
required String format,
|
||||||
String? bitrate,
|
String? bitrate,
|
||||||
bool deleteOriginal = true,
|
bool deleteOriginal = true,
|
||||||
}) async {
|
}) async {
|
||||||
// Extract bitrate value from format like 'mp3_320' -> '320k'
|
String bitrateValue = '320k';
|
||||||
String bitrateValue = '320k'; // default for mp3
|
|
||||||
if (bitrate != null && bitrate.contains('_')) {
|
if (bitrate != null && bitrate.contains('_')) {
|
||||||
final parts = bitrate.split('_');
|
final parts = bitrate.split('_');
|
||||||
if (parts.length == 2) {
|
if (parts.length == 2) {
|
||||||
@@ -338,8 +372,6 @@ class FFmpegService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Embed metadata to Opus file
|
|
||||||
/// Uses METADATA_BLOCK_PICTURE tag for cover art (OGG/Vorbis standard)
|
|
||||||
static Future<String?> embedMetadataToOpus({
|
static Future<String?> embedMetadataToOpus({
|
||||||
required String opusPath,
|
required String opusPath,
|
||||||
String? coverPath,
|
String? coverPath,
|
||||||
@@ -354,7 +386,6 @@ class FFmpegService {
|
|||||||
cmdBuffer.write('-map 0:a ');
|
cmdBuffer.write('-map 0:a ');
|
||||||
cmdBuffer.write('-c:a copy ');
|
cmdBuffer.write('-c:a copy ');
|
||||||
|
|
||||||
// Embed metadata tags (Vorbis comments)
|
|
||||||
if (metadata != null) {
|
if (metadata != null) {
|
||||||
metadata.forEach((key, value) {
|
metadata.forEach((key, value) {
|
||||||
final sanitizedValue = value.replaceAll('"', '\\"');
|
final sanitizedValue = value.replaceAll('"', '\\"');
|
||||||
@@ -362,12 +393,10 @@ class FFmpegService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Embed cover art using METADATA_BLOCK_PICTURE
|
|
||||||
if (coverPath != null) {
|
if (coverPath != null) {
|
||||||
try {
|
try {
|
||||||
final pictureBlock = await _createMetadataBlockPicture(coverPath);
|
final pictureBlock = await _createMetadataBlockPicture(coverPath);
|
||||||
if (pictureBlock != null) {
|
if (pictureBlock != null) {
|
||||||
// Escape special characters for shell
|
|
||||||
final escapedBlock = pictureBlock.replaceAll('"', '\\"');
|
final escapedBlock = pictureBlock.replaceAll('"', '\\"');
|
||||||
cmdBuffer.write('-metadata METADATA_BLOCK_PICTURE="$escapedBlock" ');
|
cmdBuffer.write('-metadata METADATA_BLOCK_PICTURE="$escapedBlock" ');
|
||||||
_log.d('Created METADATA_BLOCK_PICTURE for Opus (${pictureBlock.length} chars)');
|
_log.d('Created METADATA_BLOCK_PICTURE for Opus (${pictureBlock.length} chars)');
|
||||||
@@ -424,19 +453,6 @@ class FFmpegService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create METADATA_BLOCK_PICTURE base64 string for OGG/Opus cover art
|
|
||||||
/// Format follows FLAC picture block specification:
|
|
||||||
/// - 4 bytes: picture type (3 = front cover)
|
|
||||||
/// - 4 bytes: MIME type length
|
|
||||||
/// - n bytes: MIME type string
|
|
||||||
/// - 4 bytes: description length
|
|
||||||
/// - n bytes: description string
|
|
||||||
/// - 4 bytes: width
|
|
||||||
/// - 4 bytes: height
|
|
||||||
/// - 4 bytes: color depth
|
|
||||||
/// - 4 bytes: colors used (0 for non-indexed)
|
|
||||||
/// - 4 bytes: picture data length
|
|
||||||
/// - n bytes: picture data
|
|
||||||
static Future<String?> _createMetadataBlockPicture(String imagePath) async {
|
static Future<String?> _createMetadataBlockPicture(String imagePath) async {
|
||||||
try {
|
try {
|
||||||
final file = File(imagePath);
|
final file = File(imagePath);
|
||||||
@@ -447,7 +463,6 @@ class FFmpegService {
|
|||||||
|
|
||||||
final imageData = await file.readAsBytes();
|
final imageData = await file.readAsBytes();
|
||||||
|
|
||||||
// Detect MIME type from file extension or magic bytes
|
|
||||||
String mimeType;
|
String mimeType;
|
||||||
if (imagePath.toLowerCase().endsWith('.png')) {
|
if (imagePath.toLowerCase().endsWith('.png')) {
|
||||||
mimeType = 'image/png';
|
mimeType = 'image/png';
|
||||||
@@ -455,7 +470,6 @@ class FFmpegService {
|
|||||||
imagePath.toLowerCase().endsWith('.jpeg')) {
|
imagePath.toLowerCase().endsWith('.jpeg')) {
|
||||||
mimeType = 'image/jpeg';
|
mimeType = 'image/jpeg';
|
||||||
} else {
|
} else {
|
||||||
// Check magic bytes
|
|
||||||
if (imageData.length >= 8 &&
|
if (imageData.length >= 8 &&
|
||||||
imageData[0] == 0x89 && imageData[1] == 0x50 &&
|
imageData[0] == 0x89 && imageData[1] == 0x50 &&
|
||||||
imageData[2] == 0x4E && imageData[3] == 0x47) {
|
imageData[2] == 0x4E && imageData[3] == 0x47) {
|
||||||
@@ -464,75 +478,61 @@ class FFmpegService {
|
|||||||
imageData[0] == 0xFF && imageData[1] == 0xD8) {
|
imageData[0] == 0xFF && imageData[1] == 0xD8) {
|
||||||
mimeType = 'image/jpeg';
|
mimeType = 'image/jpeg';
|
||||||
} else {
|
} else {
|
||||||
mimeType = 'image/jpeg'; // Default to JPEG
|
mimeType = 'image/jpeg';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final mimeBytes = utf8.encode(mimeType);
|
final mimeBytes = utf8.encode(mimeType);
|
||||||
const description = ''; // Empty description
|
const description = '';
|
||||||
final descBytes = utf8.encode(description);
|
final descBytes = utf8.encode(description);
|
||||||
|
|
||||||
// Build the FLAC picture block
|
|
||||||
// Total size: 4 + 4 + mimeLen + 4 + descLen + 4 + 4 + 4 + 4 + 4 + imageLen
|
|
||||||
final blockSize = 4 + 4 + mimeBytes.length + 4 + descBytes.length +
|
final blockSize = 4 + 4 + mimeBytes.length + 4 + descBytes.length +
|
||||||
4 + 4 + 4 + 4 + 4 + imageData.length;
|
4 + 4 + 4 + 4 + 4 + imageData.length;
|
||||||
|
|
||||||
final buffer = ByteData(blockSize);
|
final buffer = ByteData(blockSize);
|
||||||
var offset = 0;
|
var offset = 0;
|
||||||
|
|
||||||
// Picture type: 3 = Front cover
|
|
||||||
buffer.setUint32(offset, 3, Endian.big);
|
buffer.setUint32(offset, 3, Endian.big);
|
||||||
offset += 4;
|
offset += 4;
|
||||||
|
|
||||||
// MIME type length
|
|
||||||
buffer.setUint32(offset, mimeBytes.length, Endian.big);
|
buffer.setUint32(offset, mimeBytes.length, Endian.big);
|
||||||
offset += 4;
|
offset += 4;
|
||||||
|
|
||||||
// MIME type string
|
|
||||||
final blockBytes = Uint8List(blockSize);
|
final blockBytes = Uint8List(blockSize);
|
||||||
blockBytes.setRange(0, offset, buffer.buffer.asUint8List());
|
blockBytes.setRange(0, offset, buffer.buffer.asUint8List());
|
||||||
blockBytes.setRange(offset, offset + mimeBytes.length, mimeBytes);
|
blockBytes.setRange(offset, offset + mimeBytes.length, mimeBytes);
|
||||||
offset += mimeBytes.length;
|
offset += mimeBytes.length;
|
||||||
|
|
||||||
// Description length
|
|
||||||
final tempBuffer = ByteData(4);
|
final tempBuffer = ByteData(4);
|
||||||
tempBuffer.setUint32(0, descBytes.length, Endian.big);
|
tempBuffer.setUint32(0, descBytes.length, Endian.big);
|
||||||
blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List());
|
blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List());
|
||||||
offset += 4;
|
offset += 4;
|
||||||
|
|
||||||
// Description string
|
|
||||||
blockBytes.setRange(offset, offset + descBytes.length, descBytes);
|
blockBytes.setRange(offset, offset + descBytes.length, descBytes);
|
||||||
offset += descBytes.length;
|
offset += descBytes.length;
|
||||||
|
|
||||||
// Width (0 = unknown)
|
|
||||||
tempBuffer.setUint32(0, 0, Endian.big);
|
tempBuffer.setUint32(0, 0, Endian.big);
|
||||||
blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List());
|
blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List());
|
||||||
offset += 4;
|
offset += 4;
|
||||||
|
|
||||||
// Height (0 = unknown)
|
|
||||||
tempBuffer.setUint32(0, 0, Endian.big);
|
tempBuffer.setUint32(0, 0, Endian.big);
|
||||||
blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List());
|
blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List());
|
||||||
offset += 4;
|
offset += 4;
|
||||||
|
|
||||||
// Color depth (0 = unknown)
|
|
||||||
tempBuffer.setUint32(0, 0, Endian.big);
|
tempBuffer.setUint32(0, 0, Endian.big);
|
||||||
blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List());
|
blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List());
|
||||||
offset += 4;
|
offset += 4;
|
||||||
|
|
||||||
// Colors used (0 for non-indexed)
|
|
||||||
tempBuffer.setUint32(0, 0, Endian.big);
|
tempBuffer.setUint32(0, 0, Endian.big);
|
||||||
blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List());
|
blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List());
|
||||||
offset += 4;
|
offset += 4;
|
||||||
|
|
||||||
// Picture data length
|
|
||||||
tempBuffer.setUint32(0, imageData.length, Endian.big);
|
tempBuffer.setUint32(0, imageData.length, Endian.big);
|
||||||
blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List());
|
blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List());
|
||||||
offset += 4;
|
offset += 4;
|
||||||
|
|
||||||
// Picture data
|
|
||||||
blockBytes.setRange(offset, offset + imageData.length, imageData);
|
blockBytes.setRange(offset, offset + imageData.length, imageData);
|
||||||
|
|
||||||
// Base64 encode the entire block
|
|
||||||
final base64String = base64Encode(blockBytes);
|
final base64String = base64Encode(blockBytes);
|
||||||
|
|
||||||
return base64String;
|
return base64String;
|
||||||
@@ -549,7 +549,6 @@ class FFmpegService {
|
|||||||
final key = entry.key.toUpperCase();
|
final key = entry.key.toUpperCase();
|
||||||
final value = entry.value;
|
final value = entry.value;
|
||||||
|
|
||||||
// Map Vorbis comments to ID3v2 frame names
|
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case 'TITLE':
|
case 'TITLE':
|
||||||
id3Map['title'] = value;
|
id3Map['title'] = value;
|
||||||
@@ -583,7 +582,6 @@ class FFmpegService {
|
|||||||
id3Map['lyrics'] = value;
|
id3Map['lyrics'] = value;
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
// Pass through other tags as-is
|
|
||||||
id3Map[key.toLowerCase()] = value;
|
id3Map[key.toLowerCase()] = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -323,7 +323,6 @@ class PlatformBridge {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns true if credentials are available (custom or env vars)
|
|
||||||
static Future<bool> hasSpotifyCredentials() async {
|
static Future<bool> hasSpotifyCredentials() async {
|
||||||
final result = await _channel.invokeMethod('hasSpotifyCredentials');
|
final result = await _channel.invokeMethod('hasSpotifyCredentials');
|
||||||
return result as bool;
|
return result as bool;
|
||||||
@@ -410,7 +409,6 @@ class PlatformBridge {
|
|||||||
return logs.map((e) => e as Map<String, dynamic>).toList();
|
return logs.map((e) => e as Map<String, dynamic>).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get logs since a specific index (for incremental updates)
|
|
||||||
static Future<Map<String, dynamic>> getGoLogsSince(int index) async {
|
static Future<Map<String, dynamic>> getGoLogsSince(int index) async {
|
||||||
final result = await _channel.invokeMethod('getLogsSince', {'index': index});
|
final result = await _channel.invokeMethod('getLogsSince', {'index': index});
|
||||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
@@ -561,7 +559,7 @@ class PlatformBridge {
|
|||||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Map<String, dynamic>> downloadWithExtensions({
|
static Future<Map<String, dynamic>> downloadWithExtensions({
|
||||||
required String isrc,
|
required String isrc,
|
||||||
required String spotifyId,
|
required String spotifyId,
|
||||||
required String trackName,
|
required String trackName,
|
||||||
@@ -584,8 +582,9 @@ class PlatformBridge {
|
|||||||
String? genre,
|
String? genre,
|
||||||
String? label,
|
String? label,
|
||||||
String lyricsMode = 'embed',
|
String lyricsMode = 'embed',
|
||||||
|
String? preferredService,
|
||||||
}) async {
|
}) async {
|
||||||
_log.i('downloadWithExtensions: "$trackName" by $artistName${source != null ? ' (source: $source)' : ''}');
|
_log.i('downloadWithExtensions: "$trackName" by $artistName${source != null ? ' (source: $source)' : ''}${preferredService != null ? ' (service: $preferredService)' : ''}');
|
||||||
final request = jsonEncode({
|
final request = jsonEncode({
|
||||||
'isrc': isrc,
|
'isrc': isrc,
|
||||||
'spotify_id': spotifyId,
|
'spotify_id': spotifyId,
|
||||||
@@ -609,6 +608,7 @@ class PlatformBridge {
|
|||||||
'genre': genre ?? '',
|
'genre': genre ?? '',
|
||||||
'label': label ?? '',
|
'label': label ?? '',
|
||||||
'lyrics_mode': lyricsMode,
|
'lyrics_mode': lyricsMode,
|
||||||
|
'service': preferredService ?? '',
|
||||||
});
|
});
|
||||||
|
|
||||||
final result = await _channel.invokeMethod('downloadWithExtensions', request);
|
final result = await _channel.invokeMethod('downloadWithExtensions', request);
|
||||||
@@ -795,7 +795,6 @@ class PlatformBridge {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get extension home feed
|
|
||||||
static Future<Map<String, dynamic>?> getExtensionHomeFeed(String extensionId) async {
|
static Future<Map<String, dynamic>?> getExtensionHomeFeed(String extensionId) async {
|
||||||
try {
|
try {
|
||||||
final result = await _channel.invokeMethod('getExtensionHomeFeed', {
|
final result = await _channel.invokeMethod('getExtensionHomeFeed', {
|
||||||
@@ -809,7 +808,6 @@ class PlatformBridge {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get extension browse categories
|
|
||||||
static Future<Map<String, dynamic>?> getExtensionBrowseCategories(String extensionId) async {
|
static Future<Map<String, dynamic>?> getExtensionBrowseCategories(String extensionId) async {
|
||||||
try {
|
try {
|
||||||
final result = await _channel.invokeMethod('getExtensionBrowseCategories', {
|
final result = await _channel.invokeMethod('getExtensionBrowseCategories', {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ class LogEntry {
|
|||||||
final String tag;
|
final String tag;
|
||||||
final String message;
|
final String message;
|
||||||
final String? error;
|
final String? error;
|
||||||
final bool isFromGo; // Track if this log came from Go backend
|
final bool isFromGo;
|
||||||
|
|
||||||
LogEntry({
|
LogEntry({
|
||||||
required this.timestamp,
|
required this.timestamp,
|
||||||
@@ -47,8 +47,6 @@ class LogBuffer extends ChangeNotifier {
|
|||||||
Timer? _goLogTimer;
|
Timer? _goLogTimer;
|
||||||
int _lastGoLogIndex = 0;
|
int _lastGoLogIndex = 0;
|
||||||
|
|
||||||
/// Whether logging is enabled (controlled by settings)
|
|
||||||
/// User must enable "Detailed Logging" in settings to capture logs
|
|
||||||
static bool _loggingEnabled = false;
|
static bool _loggingEnabled = false;
|
||||||
static bool get loggingEnabled => _loggingEnabled;
|
static bool get loggingEnabled => _loggingEnabled;
|
||||||
static set loggingEnabled(bool value) {
|
static set loggingEnabled(bool value) {
|
||||||
@@ -64,7 +62,6 @@ class LogBuffer extends ChangeNotifier {
|
|||||||
int get length => _entries.length;
|
int get length => _entries.length;
|
||||||
|
|
||||||
void add(LogEntry entry) {
|
void add(LogEntry entry) {
|
||||||
// Skip adding if logging is disabled (except for errors which are always logged)
|
|
||||||
if (!_loggingEnabled && entry.level != 'ERROR' && entry.level != 'FATAL') {
|
if (!_loggingEnabled && entry.level != 'ERROR' && entry.level != 'FATAL') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -76,7 +73,6 @@ class LogBuffer extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start polling Go backend logs
|
|
||||||
void startGoLogPolling() {
|
void startGoLogPolling() {
|
||||||
_goLogTimer?.cancel();
|
_goLogTimer?.cancel();
|
||||||
_goLogTimer = Timer.periodic(const Duration(milliseconds: 500), (_) async {
|
_goLogTimer = Timer.periodic(const Duration(milliseconds: 500), (_) async {
|
||||||
@@ -84,13 +80,11 @@ class LogBuffer extends ChangeNotifier {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stop polling Go backend logs
|
|
||||||
void stopGoLogPolling() {
|
void stopGoLogPolling() {
|
||||||
_goLogTimer?.cancel();
|
_goLogTimer?.cancel();
|
||||||
_goLogTimer = null;
|
_goLogTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch logs from Go backend since last index
|
|
||||||
Future<void> _fetchGoLogs() async {
|
Future<void> _fetchGoLogs() async {
|
||||||
try {
|
try {
|
||||||
final result = await PlatformBridge.getGoLogsSince(_lastGoLogIndex);
|
final result = await PlatformBridge.getGoLogsSince(_lastGoLogIndex);
|
||||||
@@ -103,7 +97,6 @@ class LogBuffer extends ChangeNotifier {
|
|||||||
final tag = log['tag'] as String? ?? 'Go';
|
final tag = log['tag'] as String? ?? 'Go';
|
||||||
final message = log['message'] as String? ?? '';
|
final message = log['message'] as String? ?? '';
|
||||||
|
|
||||||
// Parse timestamp (format: "15:04:05.000")
|
|
||||||
DateTime parsedTime = DateTime.now();
|
DateTime parsedTime = DateTime.now();
|
||||||
if (timestamp.isNotEmpty) {
|
if (timestamp.isNotEmpty) {
|
||||||
try {
|
try {
|
||||||
@@ -221,7 +214,6 @@ class BufferedOutput extends LogOutput {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Global logger instance for the app
|
|
||||||
final log = Logger(
|
final log = Logger(
|
||||||
printer: PrettyPrinter(
|
printer: PrettyPrinter(
|
||||||
methodCount: 0,
|
methodCount: 0,
|
||||||
|
|||||||
@@ -10,11 +10,15 @@ class BuiltInService {
|
|||||||
final String id;
|
final String id;
|
||||||
final String label;
|
final String label;
|
||||||
final List<QualityOption> qualityOptions;
|
final List<QualityOption> qualityOptions;
|
||||||
|
final bool isDisabled; // If true, service is grayed out (fallback only)
|
||||||
|
final String? disabledReason;
|
||||||
|
|
||||||
const BuiltInService({
|
const BuiltInService({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.qualityOptions,
|
required this.qualityOptions,
|
||||||
|
this.isDisabled = false,
|
||||||
|
this.disabledReason,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,6 +31,7 @@ const _builtInServices = [
|
|||||||
QualityOption(id: 'LOSSLESS', label: 'FLAC Lossless', description: '16-bit / 44.1kHz'),
|
QualityOption(id: 'LOSSLESS', label: 'FLAC Lossless', description: '16-bit / 44.1kHz'),
|
||||||
QualityOption(id: 'HI_RES', label: 'Hi-Res FLAC', description: '24-bit / up to 96kHz'),
|
QualityOption(id: 'HI_RES', label: 'Hi-Res FLAC', description: '24-bit / up to 96kHz'),
|
||||||
QualityOption(id: 'HI_RES_LOSSLESS', label: 'Hi-Res FLAC Max', description: '24-bit / up to 192kHz'),
|
QualityOption(id: 'HI_RES_LOSSLESS', label: 'Hi-Res FLAC Max', description: '24-bit / up to 192kHz'),
|
||||||
|
QualityOption(id: 'HIGH', label: 'Lossy 320kbps', description: 'MP3 or Opus (smaller files)'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
BuiltInService(
|
BuiltInService(
|
||||||
@@ -46,16 +51,11 @@ const _builtInServices = [
|
|||||||
QualityOption(id: 'HI_RES', label: 'Hi-Res FLAC', description: '24-bit / up to 96kHz'),
|
QualityOption(id: 'HI_RES', label: 'Hi-Res FLAC', description: '24-bit / up to 96kHz'),
|
||||||
QualityOption(id: 'HI_RES_LOSSLESS', label: 'Hi-Res FLAC Max', description: '24-bit / up to 192kHz'),
|
QualityOption(id: 'HI_RES_LOSSLESS', label: 'Hi-Res FLAC Max', description: '24-bit / up to 192kHz'),
|
||||||
],
|
],
|
||||||
|
isDisabled: true,
|
||||||
|
disabledReason: 'Fallback only',
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Lossy quality option (shown when enabled in settings)
|
|
||||||
const _lossyQualityOption = QualityOption(
|
|
||||||
id: 'LOSSY',
|
|
||||||
label: 'Lossy',
|
|
||||||
description: 'MP3 320kbps or Opus 128kbps',
|
|
||||||
);
|
|
||||||
|
|
||||||
/// A reusable widget for selecting download service (built-in + extensions)
|
/// A reusable widget for selecting download service (built-in + extensions)
|
||||||
class DownloadServicePicker extends ConsumerStatefulWidget {
|
class DownloadServicePicker extends ConsumerStatefulWidget {
|
||||||
final String? trackName;
|
final String? trackName;
|
||||||
@@ -112,34 +112,21 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
|||||||
|
|
||||||
/// Get quality options for the selected service
|
/// Get quality options for the selected service
|
||||||
List<QualityOption> _getQualityOptions() {
|
List<QualityOption> _getQualityOptions() {
|
||||||
final settings = ref.read(settingsProvider);
|
|
||||||
final builtIn = _builtInServices.where((s) => s.id == _selectedService).firstOrNull;
|
final builtIn = _builtInServices.where((s) => s.id == _selectedService).firstOrNull;
|
||||||
if (builtIn != null) {
|
if (builtIn != null) {
|
||||||
// Add Lossy option if enabled in settings
|
|
||||||
if (settings.enableLossyOption) {
|
|
||||||
return [...builtIn.qualityOptions, _lossyQualityOption];
|
|
||||||
}
|
|
||||||
return builtIn.qualityOptions;
|
return builtIn.qualityOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
final extensionState = ref.read(extensionProvider);
|
final extensionState = ref.read(extensionProvider);
|
||||||
final ext = extensionState.extensions.where((e) => e.id == _selectedService).firstOrNull;
|
final ext = extensionState.extensions.where((e) => e.id == _selectedService).firstOrNull;
|
||||||
if (ext != null && ext.qualityOptions.isNotEmpty) {
|
if (ext != null && ext.qualityOptions.isNotEmpty) {
|
||||||
// Add Lossy option for extensions too if enabled
|
|
||||||
if (settings.enableLossyOption) {
|
|
||||||
return [...ext.qualityOptions, _lossyQualityOption];
|
|
||||||
}
|
|
||||||
return ext.qualityOptions;
|
return ext.qualityOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default fallback options
|
// Default fallback options
|
||||||
final defaultOptions = [
|
return [
|
||||||
const QualityOption(id: 'DEFAULT', label: 'Default Quality', description: 'Best available'),
|
const QualityOption(id: 'DEFAULT', label: 'Default Quality', description: 'Best available'),
|
||||||
];
|
];
|
||||||
if (settings.enableLossyOption) {
|
|
||||||
return [...defaultOptions, _lossyQualityOption];
|
|
||||||
}
|
|
||||||
return defaultOptions;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -188,7 +175,7 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
child: Wrap(
|
child: Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
@@ -196,9 +183,14 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
|||||||
children: [
|
children: [
|
||||||
for (final service in _builtInServices)
|
for (final service in _builtInServices)
|
||||||
_ServiceChip(
|
_ServiceChip(
|
||||||
label: service.label,
|
label: service.isDisabled
|
||||||
|
? '${service.label} (${service.disabledReason})'
|
||||||
|
: service.label,
|
||||||
isSelected: _selectedService == service.id,
|
isSelected: _selectedService == service.id,
|
||||||
onTap: () => setState(() => _selectedService = service.id),
|
isDisabled: service.isDisabled,
|
||||||
|
onTap: service.isDisabled
|
||||||
|
? null
|
||||||
|
: () => setState(() => _selectedService = service.id),
|
||||||
),
|
),
|
||||||
for (final ext in downloadExtensions)
|
for (final ext in downloadExtensions)
|
||||||
_ServiceChip(
|
_ServiceChip(
|
||||||
@@ -237,8 +229,13 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
|||||||
subtitle: quality.description ?? '',
|
subtitle: quality.description ?? '',
|
||||||
icon: _getQualityIcon(quality.id),
|
icon: _getQualityIcon(quality.id),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.pop(context);
|
// For Tidal HIGH quality, show format picker first
|
||||||
widget.onSelect(quality.id, _selectedService);
|
if (_selectedService == 'tidal' && quality.id == 'HIGH') {
|
||||||
|
_showLossyFormatPicker(context);
|
||||||
|
} else {
|
||||||
|
Navigator.pop(context);
|
||||||
|
widget.onSelect(quality.id, _selectedService);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -257,9 +254,10 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
|||||||
return Icons.high_quality;
|
return Icons.high_quality;
|
||||||
case 'LOSSLESS':
|
case 'LOSSLESS':
|
||||||
return Icons.music_note;
|
return Icons.music_note;
|
||||||
|
case 'HIGH':
|
||||||
|
return Icons.aod;
|
||||||
case 'MP3_320':
|
case 'MP3_320':
|
||||||
case 'MP3':
|
case 'MP3':
|
||||||
case 'LOSSY':
|
|
||||||
return Icons.audiotrack;
|
return Icons.audiotrack;
|
||||||
case 'OPUS':
|
case 'OPUS':
|
||||||
case 'OPUS_128':
|
case 'OPUS_128':
|
||||||
@@ -268,6 +266,102 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
|||||||
return Icons.music_note;
|
return Icons.music_note;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showLossyFormatPicker(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final settings = ref.read(settingsProvider);
|
||||||
|
final currentFormat = settings.tidalHighFormat;
|
||||||
|
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||||
|
),
|
||||||
|
builder: (modalContext) => SafeArea(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
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 Lossy Format',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||||
|
child: Text(
|
||||||
|
'Choose output format for 320kbps lossy download',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
leading: Container(
|
||||||
|
padding: const EdgeInsets.all(10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.primaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Icon(Icons.audiotrack, color: colorScheme.onPrimaryContainer, size: 20),
|
||||||
|
),
|
||||||
|
title: const Text('MP3 320kbps'),
|
||||||
|
subtitle: const Text('Best compatibility, ~10MB per track'),
|
||||||
|
trailing: currentFormat == 'mp3_320'
|
||||||
|
? Icon(Icons.check_circle, color: colorScheme.primary)
|
||||||
|
: null,
|
||||||
|
onTap: () {
|
||||||
|
ref.read(settingsProvider.notifier).setTidalHighFormat('mp3_320');
|
||||||
|
Navigator.pop(modalContext); // Close format picker
|
||||||
|
Navigator.pop(context); // Close service picker
|
||||||
|
widget.onSelect('HIGH', _selectedService);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
leading: Container(
|
||||||
|
padding: const EdgeInsets.all(10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.primaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Icon(Icons.graphic_eq, color: colorScheme.onPrimaryContainer, size: 20),
|
||||||
|
),
|
||||||
|
title: const Text('Opus 128kbps'),
|
||||||
|
subtitle: const Text('Modern codec, ~4MB per track'),
|
||||||
|
trailing: currentFormat == 'opus_128'
|
||||||
|
? Icon(Icons.check_circle, color: colorScheme.primary)
|
||||||
|
: null,
|
||||||
|
onTap: () {
|
||||||
|
ref.read(settingsProvider.notifier).setTidalHighFormat('opus_128');
|
||||||
|
Navigator.pop(modalContext); // Close format picker
|
||||||
|
Navigator.pop(context); // Close service picker
|
||||||
|
widget.onSelect('HIGH', _selectedService);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -309,26 +403,32 @@ class _QualityOption extends StatelessWidget {
|
|||||||
class _ServiceChip extends StatelessWidget {
|
class _ServiceChip extends StatelessWidget {
|
||||||
final String label;
|
final String label;
|
||||||
final bool isSelected;
|
final bool isSelected;
|
||||||
final VoidCallback onTap;
|
final VoidCallback? onTap;
|
||||||
final String? iconPath;
|
final String? iconPath;
|
||||||
|
final bool isDisabled;
|
||||||
|
|
||||||
const _ServiceChip({
|
const _ServiceChip({
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.isSelected,
|
required this.isSelected,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
this.iconPath,
|
this.iconPath,
|
||||||
|
this.isDisabled = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: onTap,
|
onTap: isDisabled ? null : onTap,
|
||||||
child: AnimatedContainer(
|
child: AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isSelected ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
|
color: isDisabled
|
||||||
|
? colorScheme.surfaceContainerHighest.withValues(alpha: 0.5)
|
||||||
|
: isSelected
|
||||||
|
? colorScheme.primaryContainer
|
||||||
|
: colorScheme.surfaceContainerHighest,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: isSelected ? null : Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
|
border: isSelected ? null : Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
|
||||||
),
|
),
|
||||||
@@ -346,7 +446,11 @@ class _ServiceChip extends StatelessWidget {
|
|||||||
errorBuilder: (context, error, stackTrace) => Icon(
|
errorBuilder: (context, error, stackTrace) => Icon(
|
||||||
Icons.extension,
|
Icons.extension,
|
||||||
size: 18,
|
size: 18,
|
||||||
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
|
color: isDisabled
|
||||||
|
? colorScheme.onSurfaceVariant.withValues(alpha: 0.4)
|
||||||
|
: isSelected
|
||||||
|
? colorScheme.onPrimaryContainer
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -356,7 +460,11 @@ class _ServiceChip extends StatelessWidget {
|
|||||||
label,
|
label,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||||
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
|
color: isDisabled
|
||||||
|
? colorScheme.onSurfaceVariant.withValues(alpha: 0.4)
|
||||||
|
: isSelected
|
||||||
|
? colorScheme.onPrimaryContainer
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
+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: 3.3.1+68
|
version: 3.3.5+70
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.0
|
sdk: ^3.10.0
|
||||||
|
|||||||
Reference in New Issue
Block a user